1.Spring Security的作用

Spring Security主要解决了认证授权相关的问题。

认证(Authenticate):验证用户身份,即登录。

授权(Authorize):允许用户访问受保护的资源,即某些请求需要特定的权限,检查用户是否有权限提交这些请求。

2.Spring Security的依赖项

在Spring Boot项目中,当需要添加Spring Security的依赖时,依赖项为spring-boot-starter-security,即:

<!-- Spring Boot框架支持Security开发的依赖项,用于实现认证与授权 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId>
</dependency>

**提示:**所有以spring-boot-starter为前缀的依赖项基本都有自动配置机制。

当添加以上依赖后,你的项目会发生以下变化:

  • 所有请求都变成了必须登录的,无论请求路径是否存在,未登录时,都会重定向到/login的地址,显示Security提供的登录表单

  • Spring Security提供了默认的用户名和密码,用户名为user,密码是启用项目时随机生成一个UUID值,在启动日志中可以看到:

  • 当登录成功后,会重定向到此前尝试访问的页面,**注意:**由于此前尝试访问的页面可能本身就是不存在的,所以登录成功后可能会导致404错误

  • 当登录成功后,所有GET请求都是允许正常访问的,但是,通过Knife4j的API文档的调试功能测试访问可以发现:所有的POST请求都是不允许访问的,访问时会响应403错误

  • 当登录成功后,可以在浏览器中手动输入URL访问/logout路径,此页面是用于退出登录的:

当成功的退出登录后,会重定向到登录页面,此时,回到所有请求都需要登录的状态

3. 防止伪造的跨域攻击

默认情况下,即使登录成功,在API文档的调试功能中,所有POST请求都不能正常访问,这是因为Spring Security框架默认开启了“防止伪造的跨域攻击”这种防御机制。

伪造的跨域攻击,主要源自服务器端对客户端浏览器的信任,目前,主流的浏览器都是多选项卡的,只要在其中1个选项卡的页面中登录了,在同一个浏览器的其它任何选项卡的页面,都会是已经登录的状态,即使使用的不是多选项卡的浏览器,服务器端信任的也是整个浏览器,这是因为默认的认证机制是基于Session的,浏览器在对同一个服务器端提交请求时会自动携带同样的Session ID,所以,只要登录过,后续再携带同样的Session ID,无论是在哪个选项卡中,都会被视为“已登录”的状态!

基于这样的特点,假设用户在A选项卡中成功的登录某个银行的系统,而B选项卡打开是另一个网站,此网站中隐藏一个向银行发送转账的链接且是自动发出的,由于这2个选项卡是同一个浏览器打开的,所以,B选项卡中的页面发出的请求到了银行系统,银行系统也会视为“已登录”的状态,将执行转账操作。

PS:实际的转账还会有更多检查,例如再次输出密码、要求输出手机接收的验证码,不会如以上例子中直接转账。

Spring Security的防御机制表现为:所有POST请求必须提交某个值,这个值是由客户端向服务器端第一次发送请求时,由服务器端随机生成的,客户端会收到这个值,在后续的访问中,客户端必须提交此值,如果未提交,就会视为“伪造的跨域攻击”,将禁止访问!

例如,在Spring Security默认的登录页中:

由于我们开发的项目是前后端分离的,不可能得到以上这个随机值,所以,发出的POST请求全部被视为“伪造的跨域攻击”,所以导致了403错误!

在当前项目中,后续会实现基于JWT的认证机制,这种机制本身就是不会出现“伪造的跨域攻击”相关问题的,所以,直接将此防御机制禁用即可!

在项目的根包下创建config.ScurityConfiguration配置类,继承自WebSecurityConfigurerAdapter类,重写void configure(HttpSecurity)方法,在此方法中:

  • 不调用父类的方法,即删除通过super调用父类方法的语句

    • 删除后,默认情况下,所有的请求都不需要登录了
  • 添加 http.csrf().disable();

4. 关于请求是否需要认证

当项目添加了Spring Security的依赖后,所有请求默认都是需要认证(需要成功登录)的,当添加以上配置类,并删除了super调用父类方法后,所有请求都不再要求认证了!

在项目中,应该将某些请求配置为需要认证的,还有一些请求是不需要认证的!例如,在线API文档的相关页面应该是不需要认证即可访问的,而管理员管理的相关请求(例如添加管理员、删除管理员等)是需要认证才允许访问的!

则在配置类中的void configure(HttpSecurity)方法中添加以下配置:

// 白名单URL
// 注意:所有路径使用 / 作为第1个字符
// 可以使用 * 通配符,例如 /admins/* 可以匹配 /admins/add-new,但是,不能匹配多级,例如不能匹配到 /admins/9527/delete
// 可以使用 ** 通配符,例如 /admins/** 可以匹配若干级,例如可以匹配 /admins/add-new,也可以匹配到 /admins/9527/delete
String[] urls = {"/doc.html","/**/*.css","/**/*.js","/favicon.ico","/swagger-resources","/v2/api-docs"
};// 配置请求是否需要认证
http.authorizeRequests() // 配置请求的认证授权.mvcMatchers(urls) // 匹配某些请求路径.permitAll() // 允许直接访问,即不需要通过认证.anyRequest() // 其它任何请求.authenticated(); // 需要是通过认证的

注意:以上anyRequest()其实表示的是“任何请求”或者“所有请求”,并非“其它任何请求”!以上配置的机制是优先原则,例如“白名单”中的路径被配置为permitAll(),接下来,anyRequest()表示的范围其实也包含“白名单”中的所有路径,但是,不会覆盖此前的配置!

5. 关于默认的登录页面

Spring Security默认的登录页面也是在void configure(HttpSecurity)方法中配置的,默认情况下,父类配置中是开启了登录表单的,如果子类(自定义的配置类继承自WebSecurityConfigurerAdapter)中没有通过super调用父类的方法,则不会开启登录表单!

在没有开启登录表单的情况下,如果被视为“未认证”,将响应403错误。

如果需要开启登录表单,可以在配置方法中添加:

http.formLogin(); // 开启登录表单

6. 关于void configure(HttpSecurity)方法的配置语法

关于请求的安全配置都是在void configure(HttpSecurity http)方法中调用参数对象的方法配置的,对于配置不同的内容,可以分开来配置,即使用多条语句,每条语句都调用参数http的方法,例如:

// 所有请求都必须是通过认证的
http.authorizeRequests().anyRequest().authenticated();// 禁用“防止伪造的跨域攻击”这种防御机制
http.csrf().disable();http.formLogin(); // 开启登录表单

这些配置的设计也支持链式语法:

http.authorizeRequests().anyRequest().authenticated().and() // 重点.csrf().disable().formLogin();

简单来说,如果要使用链式语法,当“打点”后不能调用相关的配置方法,就调用and()方法,此方法会返回当前参数对象,即HttpSecurity对象,然后继续“打点”进行其它配置。

并且,以上不冲突各配置可以不区分先后顺序。

7. 使用自定义的用户名与密码登录

Spring Security在处理认证时,会自动调用UserDetailsService接口对象中的UserDetails loadUserByUsername(String username)方法,此方法是根据用户名获取用户详情的,此方法返回的结果中应该至少包括用户的密码及其它与登录密切相关的信息,例如账号的状态(是否启用等)、账号的权限等。

在整个处理过程中,Spring Security会根据表单中提交的用户名来调用此方法,并获得用户详情,接下来,由Spring Security去判断用户详情中的信息,例如密码是否正确、账号状态是否正常等。

在项目的根包下创建security.UserDetailsServiceImpl类,实现UserDetailsService接口,添加@Service注解,并重写接口中的方法:

package cn.tedu.csmall.passport.security;import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);// 假设可用的用户名/密码是 root/1234if ("root".equals(s)) {UserDetails userDetails = User.builder().username("root").password("1234").disabled(false) // 账号是否禁用.accountLocked(false) // 账号是否已锁定.accountExpired(false) // 账号是否过期.credentialsExpired(false) // 凭证是否过期.authorities("这是一个山寨的临时权限,也不知道有什么用") // 权限.build();return userDetails;}// 如果用户名不存在,暂时返回nullreturn null;}}

提示:以上类必须添加@Service注解,由于也在组件扫描的包下,所以,Spring会自动创建此类的对象,后续,Spring Security可以自动从容器中找到此类的对象并使用。

Spring Security在验证密码时,会自动调用密码编码器,则需要配置某个密码编码器,由于以上代码中配置的密码"1234"并不是密文,可以暂时使用NoOpPasswordEncoder。则在SecurityConfiguration中添加:

@Bean
public PasswordEncoder passwordEncoder() {return NoOpPasswordEncoder.getInstance(); // NoOpPasswordEncoder是“不加密”的密码编码器
}

完成后,再次启用项目,在控制台可以看到Spring Security不再生成随机的UUID密码了,所以,原本的user临时账号已经不再可用,必须使用以上类中配置的账号密码才可以登录!

登录效果如下:

  • 如果用户名错误,在页面中将提示:UserDetailsService returned null, which is an interface contract violation
  • 如果用户名正确,但密码错误,在页面中将提示:用户名或密码错
  • 如果用户名与密码均正确,将重定向到此前访问的页面,并且,在API文档中调试任何功能都是可用的

8. 关于密码编码器

Spring Security认为所有的密码都应该是加密的,框架中定义了名为PasswordEncoder的接口,接口定义如下:

public interface PasswordEncoder {// 执行编码,即:加密String encode(CharSequence var1);// 匹配密码,参数1是密码原文,参数2是密文,将返回true/false表示密码是否匹配boolean matches(CharSequence var1, String var2);// 升级编码default boolean upgradeEncoding(String encodedPassword) {return false;}
}

无论你认为密码是否需要加密,Spring Security处理认证的过程中,在验证密码是否正确时,都会自动调用此接口对象的matches()方法,如果在Spring容器中没有此接口的对象,将无法验证密码。

所以,即使使用没有加密的密码,也需要配置NoOpPasswordEncoder

在Spring Security中,提供了BCryptPasswordEncoder类,是PasswordEncoder接口的实现类,此类可以用于处理BCrypt算法的编码、验证密码,推荐使用这种密码编码器,关于BCryptPasswordEncoder类的基本使用,测试如下:

package cn.tedu.csmall.passport;import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;public class BCryptTest {PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();@Testvoid encode() {String rawPassword = "123456";System.out.println("原文:" + rawPassword);long start = System.currentTimeMillis();for (int i = 0; i < 20; i++) {String encodedPassword = passwordEncoder.encode(rawPassword);System.out.println("密文:" + encodedPassword);}long end = System.currentTimeMillis();System.out.println("耗时:" + (end - start));}//    原文:123456//    密文:$2a$10$XGvx1Y/.B.fSUt2uS3m43OaFkgZCWs.isoLjXw5O1YTbX1QE001x6//    密文:$2a$10$m1XBX0V9Jk8sGO.oZVxF5O3nxRQ/bZjKMGuBn.og74ddrvNfkR1YC//    密文:$2a$10$65z1UUvAaNHeit4GgMN8auoEx5ZXYBJI9/bG.HYQiS5YgYkqeARlG//    密文:$2a$10$CSr3Js2mu1d/LSJiVTrLQ.11STmG9lFZvO4o5zmyTAu8xOlCjwyf6//    密文:$2a$10$WYI2xGW5wJCnG7jz6qOXruDPzS6o9tO9IBdbG3eQpPpbCsvOkl1NK//    密文:$2a$10$cs4HLJCvqD8PmHYqcANiiuRpXZMy4Pf3ubbG3EIaOZ.TqyDr5iLuu@Testvoid matches() {String rawPassword = "123456";System.out.println("原文:" + rawPassword);String encodedPassword = "$2a$10$cs4HLJCvqD8PmHYqcANiiuRpXZMy4Pf3ubbG3EIaOZ.TqyDr5iLuu";System.out.println("密文:" + encodedPassword);boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);System.out.println("匹配结果:" + matches);}}

在项目中,使用BCrypt算法处理密码时,需要将PasswordEncoder的实现对象改为BCryptPasswordEncoder

@Bean
public PasswordEncoder passwordEncoder() {// return NoOpPasswordEncoder.getInstance(); // NoOpPasswordEncoder是“不加密”的密码编码器return new BCryptPasswordEncoder();
}

经过以上调整后,在UserDetailsServiceImpl中返回的UserDetails对象中的密码也必须是BCrypt算法的密文,例如:

// 假设可用的用户名/密码是 root/123456
if ("root".equals(s)) {UserDetails userDetails = User.builder().username("root")//            ↓↓↓↓↓ 调整 .password("$2a$10$XGvx1Y/.B.fSUt2uS3m43OaFkgZCWs.isoLjXw5O1YTbX1QE001x6").disabled(false) // 账号是否禁用.accountLocked(false) // 账号是否已锁定.accountExpired(false) // 账号是否过期.credentialsExpired(false) // 凭证是否过期.authorities("这是一个山寨的临时权限,也不知道有什么用") // 权限.build();return userDetails;
}

9. 使用数据库的账号登录

首先,需要在Mapper层实现“根据用户名查询管理员的登录信息”的功能。

在项目的根包下创建pojo.vo.AdminLoginInfoVO类:

package cn.tedu.csmall.passport.pojo.vo;import lombok.Data;import java.io.Serializable;
import java.time.LocalDateTime;/*** 管理员的登录信息VO类** @author java@tedu.cn* @version 0.0.1*/
@Data
public class AdminLoginInfoVO implements Serializable {/*** 数据id*/private Long id;/*** 用户名*/private String username;/*** 密码(密文)*/private String password;/*** 是否启用,1=启用,0=未启用*/private Integer enable;}

AdminMapper.xml中配置查询的SQL语句:

<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginInfoResultMap">SELECT<include refid="LoginInfoQueryFields"/>FROMams_adminWHEREusername=#{username}
</select><sql id="LoginInfoQueryFields"><if test="true">id, username, password, enable</if>
</sql><resultMap id="LoginInfoResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO"><id column="id" property="id"/><result column="username" property="username"/><result column="password" property="password"/><result column="enable" property="enable"/>
</resultMap>

AdminMapperTests中编写并执行测试:

@Test
void getLoginInfoByUsername() {String username = "root";Object queryResult = mapper.getLoginInfoByUsername(username);log.debug("根据用户名【{}】查询数据详情完成,查询结果:{}", username, queryResult);
}

完成后,调整UserDetailsServiceImpl中的具体实现:

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {@Autowiredprivate AdminMapper adminMapper;@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);log.debug("从数据库查询用户名【{}】匹配的信息,结果:{}", s, loginInfo);if (loginInfo == null) {return null; // 暂时}UserDetails userDetails = User.builder().username(loginInfo.getUsername()).password(loginInfo.getPassword()).disabled(loginInfo.getEnable() == 0).accountLocked(false) // 账号是否已锁定.accountExpired(false) // 账号是否过期.credentialsExpired(false) // 凭证是否过期.authorities("这是一个山寨的临时权限,也不知道有什么用") // 权限.build();log.debug("即将向Spring Security返回UserDetails对象:{}", userDetails);return userDetails;}}

10. 使用前后端分离的登录

目前,项目中可以通过数据库中的账号信息进行登录,但是,是通过Spring Security提供的登录表单来登录的,这不是前后端分离的做法,csmall-web-client无法与此进行交互。

要使得当前服务器端是前后端分离的模式,需要:

  • SecurityConfiguration中重写authenticationManagerBean()方法(认证管理器),并在方法上添加@Bean注解
  • 创建封装了登录信息的DTO类,即AdminLoginDTO,此类中应该封装用户名、密码这2个属性
  • 在Service层处理登录业务
    • IAdminService中添加抽象方法
    • AdminServiceImpl中实现处理登录的业务,通过AuthenticationManager对象的authenticate()方法,将用户名、密码交给Spring Security框架去执行认证过程
  • 在控制器中处理登录请求
    • 在控制器中添加新的方法,用于处理登录请求,具体的处理过程由Service层实现
    • 注意:应该将登录的请求路径配置在“白名单”中
    • 在全局异常处理器中,对登录失败时Spring Security抛出的异常进行处理

SecurityConfiguration中补充:

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();
}

在根包下创建pojo.dto.AdminLoginDTO类:

package cn.tedu.csmall.passport.pojo.dto;import lombok.Data;import java.io.Serializable;/*** 管理员登录的DTO类** @author java@tedu.cn* @version 0.0.1*/
@Data
public class AdminLoginDTO implements Serializable {/*** 用户名*/private String username;/*** 密码(原文)*/private String password;}

IAdminService中添加抽象方法:

/*** 管理员登录* @param adminLoginDTO 封装了登录参数的对象*/
void login(AdminLoginDTO adminLoginDTO);

AdminServiceImpl中,先自动装配AuthenticationManager对象,然后,重写接口中抽象方法,实现登录的业务:

@Autowired
//(认证管理器)
private AuthenticationManager authenticationManager;@Override
public void login(AdminLoginDTO adminLoginDTO) {log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);// 执行认证(认证管理器)Authentication authentication = new UsernamePasswordAuthenticationToken(adminLoginDTO.getUsername(), adminLoginDTO.getPassword());authenticationManager.authenticate(authentication);log.debug("认证通过!");
}

AdminServiceTests中编写并执行测试:

@Test
void login() {AdminLoginDTO adminLoginDTO = new AdminLoginDTO();adminLoginDTO.setUsername("wangkejing");adminLoginDTO.setPassword("123456");try {service.login(adminLoginDTO);} catch (Throwable e) {// 由于不确定Spring Security会抛出什么类型的异常// 所以,捕获的是Throwable// 并且,在处理时,应该打印信息,以了解什么情况下会出现哪种异常e.printStackTrace();}
}

当测试时使用的用户名是错误的,异常信息如下:

org.springframework.security.authentication.InternalAuthenticationServiceException: UserDetailsService returned null, which is an interface contract violation

当测试时使用的密码是错误的,异常信息如下:

org.springframework.security.authentication.BadCredentialsException: 用户名或密码错误

当测试时使用的账号是禁用的(enable值为0),异常信息如下:

org.springframework.security.authentication.DisabledException: 用户已失效

接下来,调整控制器层,先在AdminController中添加处理请求的方法:

// http://localhost:9081/admins/login
@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);adminService.login(adminLoginDTO);return JsonResult.ok();
}

然后,在SecurityConfiguration中,将登录的URL添加到白名单中:

String[] urls = {"/admins/login", // 新增"/doc.html","/**/*.css","/**/*.js","/a.jpg","/favicon.ico","/swagger-resources","/v2/api-docs"
};

完成后,还应该对登录失败时Spring Security抛出的异常进行处理,首先,在ServiceCode中添加对应的业务状态码的枚举:

public enum ServiceCode {OK(20000),ERR_BAD_REQUEST(40000),/*** 【新增】错误:登录失败,用户名或密码错误*/ERR_UNAUTHORIZED(40100),/*** 【新增】错误:登录失败,账号已经被禁用*/ERR_UNAUTHORIZED_DISABLED(40110),ERR_NOT_FOUND(40400),ERR_CONFLICT(40900);// 其它代码

然后,在GlobalExceptionHandler中处理登录失败时可能抛出的3种异常(参见之前Service层的测试结果):

@ExceptionHandler
public JsonResult handleInternalAuthenticationServiceException(InternalAuthenticationServiceException e) {log.debug("开始处理InternalAuthenticationServiceException");String message = "登录失败,用户名不存在!";return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
}@ExceptionHandler
public JsonResult handleBadCredentialsException(BadCredentialsException e) {log.debug("开始处理BadCredentialsException");String message = "登录失败,密码错误!";return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
}@ExceptionHandler
public JsonResult handleDisabledException(DisabledException e) {log.debug("开始处理DisabledException");String message = "登录失败,账号已经被禁用!";return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED_DISABLED, message);
}

全部完成后,重启项目,可以通过API文档的调试功能测试访问,无论使用什么样的账号密码,都可以响应匹配的JSON结果。

11. 关于密码加密

由于目前已经配置了PasswordEncoder,具体类型是BcryptPasswordEncoder,则Spring Security在处理认证时,会自动使用它,就要求所有的被查询出来的密码都是密文,所以,在添加管理员时,密码也需要使用这种编码进行处理成密文再保存到数据库!

AdminServiceImpl中,先自动装配PasswordEncoder对象:

@Autowired
private PasswordEncoder passwordEncoder;

然后在addNew()方法中,在插入管理员数据之前:

// 将原密码加密
String rawPassword = admin.getPassword();
String encodedPassword = passwordEncoder.encode(rawPassword);
admin.setPassword(encodedPassword);

完成后,可以通过AdminServiceTests测试检验效果

12. 关于处理异常

在处理登录时,是由Spring Security处理的,当登录失败时,会由Spring Security抛出异常,所以,可以统一处理,对于用户名错误、密码错误这种的处理方式可以是完全相同的,但,原本的异常类型并不相同,用户名不存在时抛出的是InternalAuthenticationServiceException,密码错误时抛出的是BadCredentialsException

相关异常的继承结构如下:

AuthenticationException-- BadCredentialsException(密码错误时抛出的异常)-- AuthenticationServiceException-- InternalAuthenticationServiceException(用户名不存在时抛出的异常)-- AccountStatusException-- DisabledException(账号被禁用时抛出的异常)

为了准确的表达只处理InternalAuthenticationServiceExceptionBadCredentialsException这2种异常,可以配置@ExceptionHandler注解参数,例如:

@ExceptionHandler({InternalAuthenticationServiceException.class,BadCredentialsException.class
})
public JsonResult handleAuthenticationException(AuthenticationException e) {log.debug("开始处理AuthenticationException");log.debug("异常类型:" + e.getClass().getName());log.debug("异常消息:" + e.getMessage());// log.debug("跟踪信息:");// e.printStackTrace();String message = "登录失败,用户名或密码错误!";return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
}

另外,建议在全局异常处理器中,添加对Throwable异常的处理,避免向客户端响应500错误,例如:

@ExceptionHandler
public JsonResult handleThrowable(Throwable e) {log.debug("开始处理Throwable");e.printStackTrace();String message = "服务器忙,请稍后再尝试(开发阶段,请检查服务器端控制台)!";// 需要先在ServiceCode中补充新的业务状态码ERR_UNKNOWN,值应该使用比较特殊的return JsonResult.fail(ServiceCode.ERR_UNKNOWN, message);
}

**注意:**以上代码中的e.printStackTrace();是耗时操作,可能导致线程阻塞,在许多项目中是禁止使用的,在项目上线之前,应该评估是否需要删除此行代码!

13. 关于Spring Security判断是否通过认证的标准

在Spring Security框架中,有SecurityContext,它是用于持有各用户的认证信息的,即各用户成功登录后,需要将认证信息存入到SecurityContext中,后续,Spring Security框架会自动检查SecurityContext中的认证信息,如果某个用户在SecurityContext中没有匹配的认证信息,将被视为“未通过认证”(未登录)的状态。

在项目的任何代码片段中,都可以通过SecurityContextHolder类的静态方法getContext()来获取SecuriytContext的引用,例如:

SecurityContext securityContext = SecurityContextHolder.getContext();

在处理认证的过程中,当视为“已认证”时,需要将认证信息存入到SecurityContext中!目前,是通过AuthenticationManager对象的authenticate()方法执行认证的,此方法认证通过后,会返回Authentication对象,即认证信息,将它存入到SecurityContext中即可!

则在AdminServiceImpl中的login()方法中进行调整:

@Override
public void login(AdminLoginDTO adminLoginDTO) {log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);// 执行认证Authentication authentication = new UsernamePasswordAuthenticationToken(adminLoginDTO.getUsername(), adminLoginDTO.getPassword());// ↓↓↓↓↓ 调整:获取方法的返回值Authentication authenticateResult= authenticationManager.authenticate(authentication);log.debug("认证通过!");// ↓↓↓↓↓ 新增以下2行代码// 将认证通过后得到的认证信息存入到SecurityContext中SecurityContext securityContext = SecurityContextHolder.getContext();securityContext.setAuthentication(authenticateResult);
}

15. 实现访问控制

csmall_ams.sql脚本插入中的测试数据中,已经给出了权限、角色、管理员及相关的关联测试数据,也就是说,各管理员账号都有关联的角色,各角色也有关联的权限!

当管理员尝试登录时,应该读取此管理员的权限,最终,存入到SecurityContext中,则Spring Security随时可以知道此管理员的权限,并判断是否允许执行某些操作,以实现访问控制!

首先,需要修改Mapper层原有的getLoginInfoByUsername()方法的查询,要求查出管理员的权限!需要执行的SQL语句大致是:然后,调整AdminMapper.xml中配置:

<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginInfoResultMap">SELECT<include refid="LoginInfoQueryFields"/>FROMams_adminLEFT JOIN ams_admin_role ON ams_admin.id=ams_admin_role.admin_idLEFT JOIN ams_role_permission ON ams_admin_role.role_id=ams_role_permission.role_idLEFT JOIN ams_permission ON ams_role_permission.permission_id=ams_permission.idWHEREusername=#{username}
</select><sql id="LoginInfoQueryFields"><if test="true">ams_admin.id,ams_admin.username,ams_admin.password,ams_admin.enable,ams_permission.value</if>
</sql><!-- collection标签:用于封装List结果,也可理解为1对多的结果,例如1个管理员有多个权限,则权限需要通过此标签来配置 -->
<!-- collection标签的property属性:取值为封装结果中的类的属性名 -->
<!-- collection标签的ofType属性:取值为List集合属性中的元素类型的全限定名,如果是java.lang包下的类,可以不写包名 -->
<!-- collection标签的子级:配置如何创建出ofType的对象,可以通过constructor标签配置简单的对象如何创建,也可以使用id标签和若干个result标签配置较复杂的对象 -->
<!-- 注意:当存在“非单表”查询时,即使column与property的值相同,也必须配置 -->
<resultMap id="LoginInfoResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO"><id column="id" property="id"/><result column="username" property="username"/><result column="password" property="password"/><result column="enable" property="enable"/><collection property="permissions" ofType="java.lang.String"><constructor><arg column="value"/></constructor></collection>
</resultMap>

完成后,可以使用AdminMapperTests中原有的测试方法测试访问。

接下来,调整UserDetailsServiceImpl中的实现,在向Spring Security返回UserDetails之前,向对象中封装查询出来的权限信息:

UserDetails userDetails = User.builder().username(loginInfo.getUsername()).password(loginInfo.getPassword()).disabled(loginInfo.getEnable() == 0).accountLocked(false).accountExpired(false).credentialsExpired(false).authorities(loginInfo.getPermissions().toArray(new String[]{})) // 调整此行.build();
log.debug("即将向Spring Security返回UserDetails对象:{}", userDetails);

完成后,重启项目,使用正确的用户名、密码登录状态正常的(未禁用的)账号,在控制台可以看到返回的UserDetails信息中包含此账号的权限,例如:

2022-12-14 15:03:56.504 DEBUG 3432 --- [nio-9081-exec-2] c.t.c.p.security.UserDetailsServiceImpl  :
即将向Spring Security返回UserDetails对象:
org.springframework.security.core.userdetails.User [Username=root, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[/ams/admin/add-new, /ams/admin/delete, /ams/admin/read, /ams/admin/update, /pms/album/add-new, /pms/album/delete, /pms/album/read, /pms/album/update, /pms/brand/add-new, /pms/brand/delete, /pms/brand/read, /pms/brand/update, /pms/category/add-new, /pms/category/delete, /pms/category/read, /pms/category/update, /pms/picture/add-new, /pms/picture/delete, /pms/picture/read, /pms/picture/update, /pms/product/add-new, /pms/product/delete, /pms/product/read, /pms/product/update]]

**注意:**在AdminServiceImpl处理登录时,调用的AuthenticationManager对象的authenticate()方法会返回认证结果,而认证结果中的Principal就是UserDetailsServiceImpl中返回的UserDetails对象!所以,认证结果中是包含权限列表的,在其后,将整个认证结果存入到了SecurityContext中(securityContext.setAuthentication(authenticateResult);),所以,在SecurityContext中是包含了通过认证的账号的权限列表的!

接下来,可以通过Spring Security框架来检查登录的用户是否具有权限发起某些请求!

先在SecurityConfiguration类上添加注解,以启用方法级别的安全检查(即权限检查):

@EnableGlobalMethodSecurity(prePostEnabled = true)

然后,在需要检查权限的方法上,使用@PreAuthroize注解来配置权限规则,例如在AdminController中处理“删除管理员”的请求的方法上配置权限规则:

@PreAuthorize("hasAuthority('/ams/admin/delete')") // 新增
@PostMapping("/{id:[0-9]+}/delete")
public JsonResult delete(@PathVariable Long id) {// 暂不关心
}

后续,当Spring Security检查到用户无此权限时,将抛出异常,例如:

org.springframework.security.access.AccessDeniedException: 不允许访问

所以,应该在ServiceCode中添加新的业务状态码:

/*** 错误:权限不足*/
ERR_FORBIDDEN(40300),

并且,在GlobalExceptionHandler中添加处理以上异常的方法(注意:不要导包错误):

@ExceptionHandler
public JsonResult handleAccessDeniedException(AccessDeniedException e) {log.debug("开始处理AccessDeniedException");log.debug("异常消息:" + e.getMessage());String message = "权限不足,禁止访问!";return JsonResult.fail(ServiceCode.ERR_FORBIDDEN, message);
}

完成后,启用项目,在数据表中的数据都是初始测试数据的情况下,使用root账号登录,可以删除管理员,使用其它账号登录,将因为权限不足而无法执行删除管理员操作。

15. 认证处理流程 

 

17. 识别当前登录的账号

在认证信息(Authentication)中包含Principal属性,目前,此属性就是UserDetailsServiceImpl返回的UserDetails对象。

Principal:当事人

在处理请求的方法的参数列表中,可以添加当事人类型的参数,并在此参数上添加@AuthenticationPrincipal注解,则Spring Security框架就会为此参数注入值,例如:

@PostMapping("/{id:[0-9]+}/delete")
public JsonResult delete(@PathVariable Long id,// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 新增参数 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓@AuthenticationPrincipal UserDetails userDetails) {log.debug("开始处理【根据id删除删除管理员】的请求,参数:{}", id);log.debug("当事人:{}", userDetails);log.debug("当事人的用户名:{}", userDetails.getUsername());adminService.delete(id);return JsonResult.ok();
}

当事人的信息输出如下所示:

2022-12-14 16:43:26.177 DEBUG 16240 --- [nio-9081-exec-2] c.t.c.p.controller.AdminController       : 当事人:org.springframework.security.core.userdetails.User [Username=root, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[/ams/admin/add-new, /ams/admin/delete, /ams/admin/read, /ams/admin/update, /pms/album/add-new, /pms/album/delete, /pms/album/read, /pms/album/update, /pms/brand/add-new, /pms/brand/delete, /pms/brand/read, /pms/brand/update, /pms/category/add-new, /pms/category/delete, /pms/category/read, /pms/category/update, /pms/picture/add-new, /pms/picture/delete, /pms/picture/read, /pms/picture/update, /pms/product/add-new, /pms/product/delete, /pms/product/read, /pms/product/update]]

**注意:**当处理请求的方法添加了新的参数后,API文档默认视为此参数是由客户端提交的,实际上此参数是由Spring Security框架注入值的,所以,为了避免API文档错误显示,应该在此参数上补充添加@ApiIgnore注解,例如声明为@ApiIgnore @AuthenticationPrincipal UserDetails userDetails

18. 登录信息中应该包括id或其它扩展信息

**提示:**在认证信息(Authentication)中包含Principal属性,目前,此属性就是UserDetailsServiceImpl返回的UserDetails对象。

所以,可以自定义类,实现UserDetails接口,或继承自User(此类是UserDetails接口的实现类),然后,在UserDetailsServiceImpl中返回自定义类的对象,则认证信息中的Principal就是自定义类的对象!

则在根包下创建security.AdminDetails继承自User类,以扩展出id属性:

package cn.tedu.csmall.passport.security;import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;import java.util.Collection;@Getter
@EqualsAndHashCode
@ToString(callSuper = true)
public class AdminDetails extends User {/*** 管理员ID*/private Long id;public AdminDetails(Long id, String username, String password, boolean enabled,Collection<? extends GrantedAuthority> authorities) {super(username, password, enabled, true, true, true, authorities);this.id = id;}}

接下来,应该在UserDetailsServiceImpl中返回AdminDetails类型的对象:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);log.debug("从数据库查询用户名【{}】匹配的信息,结果:{}", s, loginInfo);if (loginInfo == null) {return null; // 暂时}// 创建权限列表// AdminDetails的构造方法要求是Collection<? extends GrantedAuthority>类型的// 在Mapper查询结果中的权限是List<String>类型的,所以需要遍历再创建得到所需的权限列表List<String> permissions = loginInfo.getPermissions();List<SimpleGrantedAuthority> authorities = new ArrayList<>();for (String permission : permissions) {SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission);authorities.add(authority);}// 创建AdminDetails类型的对象// 此类型是基于User类型扩展的,可以有自定义属性,例如idAdminDetails adminDetails = new AdminDetails(loginInfo.getId(), loginInfo.getUsername(), loginInfo.getPassword(),loginInfo.getEnable() == 1, authorities);log.debug("即将向Spring Security返回UserDetails对象:{}", adminDetails);return adminDetails;
}

后续,当需要获取当事人信息时,改为注入AdminDetails类型的对象即可:

@PostMapping("/{id:[0-9]+}/delete")
public JsonResult delete(@PathVariable Long id,//                                  ↓↓↓↓↓ 声明成自定义类型@ApiIgnore @AuthenticationPrincipal AdminDetails adminDetails) {log.debug("开始处理【根据id删除删除管理员】的请求,参数:{}", id);log.debug("当事人:{}", adminDetails);log.debug("当事人的ID:{}", adminDetails.getId()); // 获取扩展的属性log.debug("当事人的用户名:{}", adminDetails.getUsername());adminService.delete(id);return JsonResult.ok();
}

65. 添加管理员时处理角色

添加管理员时,必须为新管理员分配至少1种角色,否则,管理员没有角色,就无法对应到某些权限,则此新管理员的账号基本上没有意义!

所以,需要在Service层进行调整,原本只是往管理员表中添加数据,现在,在同一个“添加管理员”的业务中,补充向“管理员与角色的关联表”中也添加数据。

则首先需要实现Mapper层的“向管理员与角色的关联表中批量插入数据”的功能(同一个管理员可以有多种角色),此功能此前已实现。

然后,在AdminAddNewDTO中添加新的属性,表示在添加管理员的页面中应该勾选的若干个角色ID:

/*** 若干个角色的ID*/
private Long[] roleIds;

AdminServiceImpl中,补充自动装配AdminRoleMapper对象:

@Autowired
private AdminRoleMapper adminRoleMapper;

在调用AdminServiceImpl中的addNew()方法中,补充“批量插入管理员与角色的关联数据”:

// 准备批量插入管理员与角色的关联数据
Long adminId = admin.getId();
Long[] roleIds = adminAddNewDTO.getRoleIds();
AdminRole[] adminRoleList = new AdminRole[roleIds.length];
for (int i = 0; i < roleIds.length; i++) {AdminRole adminRole = new AdminRole();adminRole.setAdminId(adminId);adminRole.setRoleId(roleIds[i]);adminRoleList[i] = adminRole;
}
adminRoleMapper.insertBatch(adminRoleList);

完成后,可通过AdminServiceTests中的测试来验证执行效果,测试时,务必要给测试数据设置roleIds的值,例如:

@Test
void addNew() {AdminAddNewDTO admin = new AdminAddNewDTO();admin.setUsername("管理员009");admin.setPassword("123456");admin.setPhone("13900139009");admin.setEmail("13900139009@baidu.com");admin.setRoleIds(new Long[]{4L, 5L, 6L}); // 重要try {service.addNew(admin);log.debug("添加数据完成!");} catch (ServiceException e) {log.debug("添加数据失败!名称已经被占用!");}
}

67. 调整“删除管理员”的业务

由于添加管理员时向ams_adminams_admin_role这2张表中都插入了数据,那么,当删除管理员时,也应该同时删除这2张表中的相关数据!

**提示:**关于“根据管理员id删除关联表中的数据”的Mapper层功能,此前已经完成。

则调整AdminServiceImpldelete()方法的实现(需要提前在ServiceCode中添加对应的业务状态码的枚举):

// 执行删除--管理员表
log.debug("即将执行删除数据,参数:{}", id);
int rows = adminMapper.deleteById(id);
if (rows != 1) {String message = "删除管理员失败,服务器忙,请稍后再尝试!";log.warn(message);throw new ServiceException(ServiceCode.ERR_DELETE, message);
}// 执行删除--管理员与角色的关联表
rows = adminRoleMapper.deleteByAdminId(id);
if (rows < 1) {String message = "删除管理员失败,服务器忙,请稍后再尝试!";log.warn(message);throw new ServiceException(ServiceCode.ERR_DELETE, message);
}

68. 关于Session的弊端

使用Session保存用户状态,存在几个问题:

  • Session必须设置一个较短的有效期(通常不会超过半小时),超时将删除对应的Session数据,以缓存内存的压力

    • 此问题对于Session机制几乎无解
  • Session是保存在服务器端内存中的数据,默认不支持集群项目
    • 此问题可以通过技术解决,例如使用共享Session

69. 关于Token

Token:票据;令牌。也就是身份的凭证。

Token机制的典型表现:当用户成功登录后,服务器端会生成并响应一个Token到客户端,此Token上记录了用户的身份信息,此后,客户端在每次请求时都携带Token到服务器端,服务器端会先验证Token的真伪,当Token有效时,就可以根据Token上记录的信息来识别用户的身份。

Token是典型的解决集群系统甚至分布式系统中识别用户身份的解决方案。

由于Token本身并不占用服务器端的内存空间,所以,可以长时间的表示用户的身份,例如10天、15天甚至更久,这是Session机制无法解决的问题。

对于服务器端而言,主要解决几个问题:生成Token、验证Token真伪、解析Token。

70. 关于JWT

JWTJson Web Token

Token上可能需要记录用户身份的多项数据,例如idusername等,这些数据应该被有效的组织起来,以至于后续服务器端能够验证真伪,并解析出其中的数据,使用JWT时,这些数据是使用JSON格式组织起来的。

关于JWT的使用,有一套固定的标准,它约定了JWT数据的组成部分,必须包含:

  • 头部信息(Header)
  • 载荷(Payload):数据
  • 数据签名(Signature)

具体可参见:JSON Web Tokens - jwt.ioJSON Web Token (JWT) is a compact URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is digitally signed using JSON Web Signature (JWS).https://jwt.io/

71. 使用JWT

首先,需要添加相关的依赖项,推荐的依赖项有:

<!-- JJWT(Java JWT) -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version>
</dependency>

生成JWT和解析JWT的示例(测试)代码如下:

package cn.tedu.csmall.passport;import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;import java.util.Date;
import java.util.HashMap;
import java.util.Map;public class JwtTests {// 是一个自定义的字符串,应该是一个保密数据,最低要求不少于4个字符,但推荐使用更加复杂的字符串String secretKey = "fdsFOj4tp9Dgvfd9t45rDkFSLKgfR8ou";@Testvoid generate() {// JWT的过期时间Date date = new Date(System.currentTimeMillis() + 5 * 60 * 1000);// 你要存入到JWT中的数据Map<String, Object> claims = new HashMap<>();claims.put("id", 9527);claims.put("username", "test-jwt");claims.put("phone", "13800138001");String jwt = Jwts.builder() // 获取JwtBuilder,准备构建JWT数据// 【1】Header:主要配置alg(algorithm:算法)和typ(type:类型)属性.setHeaderParam("alg", "HS256").setHeaderParam("typ", "JWT")// 【2】Payload:主要配置Claims,把你要存入的数据放进去.setClaims(claims)// 【3】Signature:主要配置JWT的过期时间、签名的算法和secretKey.setExpiration(date).signWith(SignatureAlgorithm.HS256, secretKey)// 完成.compact(); // 得到JWT数据System.out.println(jwt);}@Testvoid parse() {// 需要被解析的JWT,在复制此数据时,切记不要多复制了换行符(\n)String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwaG9uZSI6IjEzODAwMTM4MDAxIiwiaWQiOjk1MjcsImV4cCI6MTY3MTA5NTI3MiwidXNlcm5hbWUiOiJ0ZXN0LWp3dCJ9.9aHPOE-JLjCqd9sKEehoZzqGhz7hpsYcUwIzpiVdfmg";// 执行解析Claims claims = Jwts.parser() // 获得JWT解析工具.setSigningKey(secretKey).parseClaimsJws(jwt).getBody();// 从Claims中获取生成时存入的数据Object id = claims.get("id");Object username = claims.get("username");Object phone = claims.get("phone");System.out.println("id = " + id);System.out.println("username = " + username);System.out.println("phone = " + phone);}}

当尝试解析JWT时,如果JWT已经过期,则会出现io.jsonwebtoken.ExpiredJwtException

io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-12-15T17:07:52Z. Current time: 2022-12-15T17:25:22Z, a difference of 1050481 milliseconds.  Allowed clock skew: 0 milliseconds.

当尝试解析JWT时,如果验证签名失败,则会出现io.jsonwebtoken.SignatureException

io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

当尝试解析JWT时,如果因为JWT数据有误导致解析失败,则会出现io.jsonwebtoken.MalformedJwtException

io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"phone":"138001","id":9527,"exp":16%rname":"test-jwt"}

**注意:**不要在JWT中存入敏感信息,或核心数据,在不知道secretKey的情况下,依然可以根据JWT解析出相关数据,只是签名验证失败而已!例如,你可以将生成好的JWT粘贴到JWT官网上,可以看到JWT将解析出Claims中的信息,但是,会提示验证签名失败!所以,对于验证签名失败的JWT应该视为“错误的”,或“不可信任的”。

72. 登录成功后响应JWT

首先,需要修改IAdminServicelogin()方法的返回值类型,由void改为String,表示认证通过后将返回JWT数据:

/*** 管理员登录** @param adminLoginDTO 封装了登录参数的对象* @return 管理员登录成功后将得到的JWT*/
String login(AdminLoginDTO adminLoginDTO);

并且,修改AdminServiceImpl中重写的方法:

@Override
public String login(AdminLoginDTO adminLoginDTO) {log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);// 执行认证Authentication authentication = new UsernamePasswordAuthenticationToken(adminLoginDTO.getUsername(), adminLoginDTO.getPassword());Authentication authenticateResult= authenticationManager.authenticate(authentication);log.debug("认证通过!");log.debug("认证结果:{}", authenticateResult); // 注意:此认证结果中的Principal就是UserDetailsServiceImpl中返回的UserDetails对象// 从认证结果中取出将要存入到JWT中的数据Object principal = authenticateResult.getPrincipal();AdminDetails adminDetails = (AdminDetails) principal;Long id = adminDetails.getId();String username = adminDetails.getUsername();// 将认证通过后得到的认证信息存入到SecurityContext中// 【注意】注释以下2行代码后,在未完成JWT验证流程之前,用户的登录将不可用// SecurityContext securityContext = SecurityContextHolder.getContext();// securityContext.setAuthentication(authenticateResult);// ===== 生成并返回JWT =====// 是一个自定义的字符串,应该是一个保密数据,最低要求不少于4个字符,但推荐使用更加复杂的字符串String secretKey = "fdsFOj4tp9Dgvfd9t45rDkFSLKgfR8ou";// JWT的过期时间Date date = new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000);// 你要存入到JWT中的数据Map<String, Object> claims = new HashMap<>();claims.put("id", id);claims.put("username", username);// claims.put("权限", "???"); // TODO 待处理String jwt = Jwts.builder() // 获取JwtBuilder,准备构建JWT数据// 【1】Header:主要配置alg(algorithm:算法)和typ(type:类型)属性.setHeaderParam("alg", "HS256").setHeaderParam("typ", "JWT")// 【2】Payload:主要配置Claims,把你要存入的数据放进去.setClaims(claims)// 【3】Signature:主要配置JWT的过期时间、签名的算法和secretKey.setExpiration(date).signWith(SignatureAlgorithm.HS256, secretKey)// 完成.compact(); // 得到JWT数据log.debug("即将返回JWT数据:{}", jwt);return jwt;
}

完成后,可以在AdminServiceTests中测试,当登录成功后,在日志中可以看到JWT数据:

@Test
void login() {AdminLoginDTO adminLoginDTO = new AdminLoginDTO();adminLoginDTO.setUsername("liucangsong");adminLoginDTO.setPassword("123456");try {String jwt = service.login(adminLoginDTO);log.debug("登录成功,JWT:{}", jwt);} catch (Throwable e) {// 由于不确定Spring Security会抛出什么类型的异常// 所以,捕获的是Throwable// 并且,在处理时,应该打印信息,以了解什么情况下会出现哪种异常e.printStackTrace();}
}

以上测试时在控制台中输出的JWT,可以在JwtTests测试类中成功解析(注意:使用相同的secretKey)。

完成后,调整AdminController中处理登录的请求,当登录成功后,向客户端响应JWT数据:

// http://localhost:9081/admins/login
@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);// ↓↓↓↓↓↓↓ 获取调用方法的返回结果,即JWT数据String jwt = adminService.login(adminLoginDTO);//                   ↓↓↓ 将JWT封装到响应对象中return JsonResult.ok(jwt);
}

完成后,重启项目,通过Knife4j的API文档进行调试,当登录成功后,将响应JWT数据。

73. 在服务器端验证并解析JWT

**【重要】**当客户端成功通过认证(登录成功)后,客户端将得到服务器端响应的JWT,在后续的访问中,客户端有义务携带JWT来向服务器端发起请求,如果客户端未携带JWT,即使此前成功通过认证,服务器端也将视为“未通过认证(未登录)”。

**【重要】**服务器端应该尝试接收客户端携带的JWT数据,并尝试解析,并将解析得到的数据(例如管理员的id、用户名等)用于创建认证对象(Authentication),将此认证对象存入到SecurityContext中。

**【重要】**Spring Security框架始终根据SecurityContext中有没有认证信息来判断是否通过认证(是否已成功登录),也通过此认证信息来检查权限。

关于客户端携带JWT与服务器端接收JWT,业内惯用的做法是:服务器端会在请求头中名为Authorization的属性中获取JWT,则客户端应该按照此标准来提交请求。

在服务器端,应该在接收到任何请求的第一时间,就尝试获取JWT,则可以选用**过滤器(Filter)**来实现此效果。

在项目的根包下创建filter.JwtAuthorizationFilter类,继承自OncePerRequestFilter类,在类上添加@Component注解,并尝试获取JWT:

@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,FilterChain filterChain) throws ServletException, IOException {// 尝试从请求头中获取JWTString jwt = request.getHeader("Authorization");log.debug("尝试从请求头中获取JWT,结果:{}", jwt);// 放行请求,由后续的组件继续处理filterChain.doFilter(request, response);}}

为了保证此过滤器能正常参与到Spring Security的处理流程中,需要在SecurityConfiguration中自动装配此过滤器的对象:

@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;

并在void configure(HttpSecurity http)方法中,将其添加:

// 将JWT过滤器添加在Spring Security的“用户名密码认证信息过滤器”之前
http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);

在Knife4j的API文档调试功能中,可以携带自定义的请求头数据:

完成后,重启服务器端项目,在API文档的调试中,发起任何请求,都可以在服务器端控制台看到接收到了JWT数据。

当接收到JWT后,应该对JWT数据进行最基本的检查,然后再尝试解析,例如,当接收到的JWT为null时,肯定没有必要尝试解析,同理,如果JWT为""空字符串,或仅有空白(空格、TAB制表位等)组成的字符串,都可以视为是无效的,可以通过StringUtils.hasText()方法进行检查。

另外,一个有效的JWT应该是Header.Payload.Signature这3部分组成的,各部分使用小数点分隔,其中,Header部分固定为36字符,Signature部分固定为43字符,中间的Payload根据存入的数据长度来决定,整个JWT数据的总长度至少113字符,所以,还可以做进一步的检查,例如要求JWT的长度至少113,甚至使用正则表达式进行检查。

需要注意的是:如果客户端携带的JWT是无效的,应该执行“放行”操作,不要因为JWT基本格式无效就返回错误,执行“放行”后,还会有Spring Security的其它组件来处理此请求,例如“白名单”路径的请求可以正常访问,其它路径的请求在没有获取到认证信息时将返回403

所以,在接收到JWT后,先进行基本格式的检查:

// 检查是否获取到了有效的JWT
if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {// 对于无效的JWT,放行请求,由后续的组件继续处理log.debug("获取到的JWT被视为无效,当前过滤器将放行……");filterChain.doFilter(request, response);return;
}

接下来,即可尝试解析JWT:

// 尝试解析JWT
log.debug("获取到的JWT被视为有效,准备解析JWT……");
String secretKey = "fdsFOj4tp9Dgvfd9t45rDkFSLKgfR8ou";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();// 从Claims中获取生成时存入的数据
Long id = claims.get("id", Long.class);
String username = claims.get("username", String.class);
log.debug("从JWT中解析得到id:{}", id);
log.debug("从JWT中解析得到username:{}", username);

接下来,就可以把解析得到的管理员信息用于创建认证对象(Authentication)并存入到SecurityContext中。

典型的Authentication实现类是UsernamePasswordAuthenticationToken,这个类型有2个构造方法:

public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {// 暂不关心方法体的代码
}public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {// 暂不关心方法体的代码
}

可以看到,这类认证信息中包括:当事人(Principal)、凭证(Credentials)、权限(Authorities)。

本次使用的认证对象中应该包括:当事人、权限,则后续Spring Security可以为添加了@AuthenticationPrincipal的参数注入值(当事人),或执行@PreAuthorize配置的方法上的权限检查,同时,由于此认证对象不用于判断密码,所以,不需要包括凭证部分。

由于当事人是一个Object类型的属性,想要同时表示idusername,必须将这2个属性封装起来,则在sercurity包下创建LoginPrincipal类型:

@Data
public class LoginPrincipal implements Serializable {/*** 数据id*/private Long id;/*** 用户名*/private String username;}

JwtAuthorizationFilter中,将解析JWT得到的idusername封装起来,用于创建Authentication对象(由于权限暂未处理,暂时随便创建一个):

// 将解析JWT得到的管理员信息创建成为AdminPrincipal(当事人)对象
LoginPrincipal loginPrincipal = new LoginPrincipal();
loginPrincipal.setId(id);
loginPrincipal.setUsername(username);// 准备管理员权限
// 【临时】
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("/ams/admin/read"));// 创建Authentication对象,将存入到SecurityContext中
// 此Authentication对象必须包含:当事人(Principal)、权限(Authorities),不必包含凭证(Credentials)
Authentication authentication = new UsernamePasswordAuthenticationToken(loginPrincipal, null, authorities);

最后,将认证信息存入到SecurityContext中,并放行:

/ 将Authentication对象存入到SecurityContext中
log.debug("即将向SecurityContext中存入认证信息:{}", authentication);
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);// 放行请求,由后续的组件继续处理
log.debug("JWT过滤器执行完毕,放行!");
filterChain.doFilter(request, response);

至此,已经完成此过滤器的基础代码,包括:接收JWT、解析JWT、将登录信息创建为认证对象、将认证对象存入到SecurityContext中,当此过滤器放行后,后续执行的Spring Security组件就可以通过SecurityContext中的认证信息判断出“已经通过认证”的状态,并能够判断权限。

所以,重启项目,在API文档的调试中,携带有效的JWT即可成功进行相关访问。

**需要注意:**由于SecurityContext本身也是基于Session的,所以,在调试时,只要曾经携带有效的JWT访问过,就会将认证信息存入到SecurityContext中,在接下来的一段时间内,只要Session没有超时,即使不携带JWT也可以成功访问!其实,目前已经不再需要使用Session了,而且,不携带有效的JWT的访问本身就可以视为“无效”,所以,在当前过滤器刚刚执行时,可以清除SecurityContext,则SecurityContext中将不再包含认证信息,就可以避免刚才这种情况(曾经存入过认证信息,Session未超时就可以不携带JWT),从而实现“每次请求都必须携带JWT才会有认证信息”的效果。

关于清除SecurityContext的代码(以下代码应该在过滤器中方法的最开始位置):

// 清空SecurityContext
// 则SecurityContext中不再包含Authentication对象,也就没了认证信息
// 避免前序请求携带JWT且解析成功后向SecurityContext中存入认证信息,后续未超时的请求都可以不携带JWT的“问题”
SecurityContextHolder.clearContext();

至此,JwtAuthorizationFilter的完整代码为:

package cn.tedu.csmall.passport.filter;import cn.tedu.csmall.passport.security.LoginPrincipal;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;/*** <p>JWT过滤器</p>** <p>此JWT的作用:</p>* <ul>*     <li>获取客户端携带的JWT,要求客户端将JWT存放在请求头的Authorization属性中</li>*     <li>解析客户端携带的JWT,并创建Authentication对象,存入到SecurityContext中</li>* </ul>** @author java@tedu.cn* @version 0.0.1*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {public static final int JWT_MIN_LENGTH = 113;public JwtAuthorizationFilter() {log.debug("创建过滤器对象:JwtAuthorizationFilter");}@Overrideprotected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain filterChain) throws ServletException, IOException {// 清空SecurityContext// 则SecurityContext中不再包含Authentication对象,也就没了认证信息// 避免前序请求携带JWT且解析成功后向SecurityContext中存入认证信息,后续未超时的请求都可以不携带JWT的“问题”SecurityContextHolder.clearContext();// 尝试从请求头中获取JWTString jwt = request.getHeader("Authorization");log.debug("尝试从请求头中获取JWT,结果:{}", jwt);// 检查是否获取到了有效的JWTif (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {// 对于无效的JWT,放行请求,由后续的组件继续处理log.debug("获取到的JWT被视为无效,当前过滤器将放行……");filterChain.doFilter(request, response);return;}// 尝试解析JWTlog.debug("获取到的JWT被视为有效,准备解析JWT……");String secretKey = "fdsFOj4tp9Dgvfd9t45rDkFSLKgfR8ou";Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();// 从Claims中获取生成时存入的数据Long id = claims.get("id", Long.class);String username = claims.get("username", String.class);log.debug("从JWT中解析得到id:{}", id);log.debug("从JWT中解析得到username:{}", username);// 将解析JWT得到的管理员信息创建成为AdminPrincipal(当事人)对象LoginPrincipal loginPrincipal = new LoginPrincipal();loginPrincipal.setId(id);loginPrincipal.setUsername(username);// 准备管理员权限// 【临时】List<SimpleGrantedAuthority> authorities = new ArrayList<>();authorities.add(new SimpleGrantedAuthority("/ams/admin/read"));// 创建Authentication对象,将存入到SecurityContext中// 此Authentication对象必须包含:当事人(Principal)、权限(Authorities),不必包含凭证(Credentials)Authentication authentication= new UsernamePasswordAuthenticationToken(loginPrincipal, null, authorities);// 将Authentication对象存入到SecurityContext中log.debug("即将向SecurityContext中存入认证信息:{}", authentication);SecurityContext securityContext = SecurityContextHolder.getContext();securityContext.setAuthentication(authentication);// 放行请求,由后续的组件继续处理log.debug("JWT过滤器执行完毕,放行!");filterChain.doFilter(request, response);}}

此时仍有一些问题没有解决,例如:

  • 没有使用正确的权限

    • 以上代码中为每个携带JWT的请求给了一个固定的临时权限,与当前管理员的实际权限不符
  • 没有处理解析JWT可能出现的异常
  • 部分代码需要更规范
    • 解析JWT时使用的secretKey是当前类的局部变量,在生成JWT时也有一个同样的局部变量,导致在2个类中都声明了完全相同的局部变量,是不合理的

      74. 关于处理权限

      AdminServiceImpl处理“管理员登录”的业务时,如果登录成功,应该将权限写入到JWT中,则客户端收到的JWT就包含了权限信息,后续客户端携带“包含权限信息”的JWT提交请求,则JWT过滤器可以解析得到权限,并存入到SecurityContext中去。

      为了保证权限列表(Collection<? extends GrantedAuthority>)写入到JWT中后,解析时还可以还原成原本的类型,应该在写入JWT之前,将权限列表转换成JSON格式的字符串,后续,解析JWT时,将此JSON格式的字符串还原成Collection<? extends GrantedAuthority>类型。

      可以使用fastjson实现对象与JSON的相互转换,则在pom.xml中添加依赖:

      <!-- fastjson:实现对象与JSON的相互转换 -->
      <dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.75</version>
      </dependency>

然后,在AdminServiceImpl处理“管理员登录”的业务中,当通过认证后:

Collection<GrantedAuthority> authorities = adminDetails.getAuthorities();
String authoritiesJsonString = JSON.toJSONString(authorities);
log.debug("认证结果中的当事人authorities:{}", authorities);
log.debug("认证结果中的当事人authoritiesJsonString:{}", authoritiesJsonString);

并将JSON字符串写入到JWT中:

// 你要存入到JWT中的数据
Map<String, Object> claims = new HashMap<>();
claims.put("id", id);
claims.put("username", username);
claims.put("authorities", authoritiesJsonString); // 新增

JwtAuthorizationFilter中即可从JWT中解析得到权限列表的JSON字符串:

String authoritiesJsonString = claims.get("authorities", String.class);
log.debug("从JWT中解析得到authoritiesJsonString:{}", authoritiesJsonString);

并将其转换成所需的集合类型:

/ 准备管理员权限
List<SimpleGrantedAuthority> authorities= JSON.parseArray(authoritiesJsonString, SimpleGrantedAuthority.class);

至此,各管理员登录后,在认证信息中都有各自对应的权限(仍是根据JWT识别管理员)。

75. 处理解析JWT时可能出现的异常

解析JWT时可能出现异常,但是,并不能使用此前的全局异常处理器来处理这几种异常,因为解析JWT是发生在过滤器中,过滤器是最早接收到请求的组件,此时其它组件(包括控制器、全局异常处理器等)都还没有开始处理此请求,而全局异常处理器只能处理由控制器抛出的异常,所以,此处并不适用,只能使用try...catch进行处理。

ServiceCode中添加新的业务状态码,以对应解析JWT时可能出现的异常,然后,使用try...catch包裹解析JWT的代码,以处理异常:

// 设置响应的文档类型,用于处理异常时
response.setContentType("application/json;charset=utf-8");// 尝试解析JWT
log.debug("获取到的JWT被视为有效,准备解析JWT……");
String secretKey = "fdsFOj4tp9Dgvfd9t45rDkFSLKgfR8ou";
Claims claims = null;
try {claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
} catch (ExpiredJwtException e) {String message = "您的登录信息已过期,请重新登录!";log.warn("解析JWT时出现ExpiredJwtException,响应的消息:{}", message);JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_EXPIRED, message);PrintWriter writer = response.getWriter();writer.println(JSON.toJSONString(jsonResult));writer.close();return;
} catch (SignatureException e) {String message = "非法访问!";log.warn("解析JWT时出现SignatureException,响应的消息:{}", message);JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_SIGNATURE, message);PrintWriter writer = response.getWriter();writer.println(JSON.toJSONString(jsonResult));writer.close();return;
} catch (MalformedJwtException e) {String message = "非法访问!";log.warn("解析JWT时出现MalformedJwtException,响应的消息:{}", message);JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_MALFORMED, message);PrintWriter writer = response.getWriter();writer.println(JSON.toJSONString(jsonResult));writer.close();return;
} catch (Throwable e) {String message = "服务器忙,请稍后再尝试(开发阶段,请检查服务器端控制台)!";log.warn("解析JWT时出现{},响应的消息:{}", e.getClass().getName(), message);e.printStackTrace();JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_UNKNOWN, message);PrintWriter writer = response.getWriter();writer.println(JSON.toJSONString(jsonResult));writer.close();return;
}

application-dev.yml中添加自定义配置:

# 当前项目中的自定义配置
csmall:# JWT相关配置jwt:# 生成和解析JWT时使用的secretKey,此属性的值不得少于4个字符,建议在30~60字符之间,应该是一个不容易被猜测的值secret-key: fdsFOj4tp9Dgvfd9t45rDkFSLKgfR8ou

然后,在JwtAuthorizationFilterAdminServiceImpl中,都不再使用原有的secretKey局部变量(删除原有的那条语句),改为在类中通过@Value注解为全局的secretKey属性(新声明的)注入值:

@Value("${csmall.jwt.secret-key}")
private String secretKey;

**注意:**如果你在配置文件中使用了新的secretKey值,请重新配置API文档中的全局参数,否则,将导致原本已经配置的JWT无法正常解析。

另外,也可以把JWT的有效时长也配置在application-dev.yml中:

csmall:jwt:secret-key: fdsFOj4tp9Dgvfd9t45rDkFSLKgfR8ou# 【新增】JWT的有效时长,以分钟为单位duration-in-minute: 10080

并在AdminServiceImpl类中将此配置值读取到属性中:

@Value("${csmall.jwt.duration-in-minute}")
private long durationInMinute;

然后,在设置JWT过期时间时,应用此配置值:

// JWT的过期时间
Date date = new Date(System.currentTimeMillis() + durationInMinute * 60 * 1000);

76. 关于复杂请求的预检(PreFlight)

浏览器在发起请求时,如果请求头配置了特定的属性,例如Authorization,则此请求会被视为复杂请求,就会触发**预检(PreFlight)**机制,则浏览器会向此请求路径先出一个OPTIONS类型的请求,如果这个OPTIONS类型的请求被拒绝,则无法发出原本尝试发出的请求。

在服务器端的SecurityConfiguration中,对所有OPTIONS的请求直接放行,即可解决此问题:

// 配置请求是否需要认证
http.authorizeRequests().mvcMatchers(HttpMethod.OPTIONS, "/**")  // 新增.permitAll()  // 新增.mvcMatchers(urls).permitAll().anyRequest().authenticated();

或者,在此配置类中,调用参数对象的cors()方法,启用Security框架自带的CorsFilter过滤器,可以对OPTIONS请求放行,也可以解决此问题:

// 启用Security框架自带的CorsFilter过滤器,可以对OPTIONS请求放行
http.cors();

提示:对于复杂请求会先发起OPTIONS请求进行预检,这是浏览器自主的行为,当浏览器对某个URL的预检请求被通过后,浏览器会将此状态缓存下来,后续,对此URL的请求不会再执行预检!

77. 单点登录

单点登录:SSO = Single Sign On,表现为“在分布式项目中,用户只需要在某1个服务上登录,后续,即使用户请求了其它服务器,其它服务器也能够识别用户的身份”。

例如,当你在 www.baidu.com 上登录后,在 pan.baidu.com 或 tieba.baidu.com 上都不需要再次登录,而是一个已经登录的状态!

目前,已经在csmall-passport项目中完成了“单点登录”中最复杂的代码,即管理员登录成功后向客户端响应JWT数据,在接下来的访问中,客户端应该携带JWT表明自己的身份,如果访问到的是csmall-product服务器,在csmall-product中,只要具备“解析JWT,且解析成功后将认证信息存入到SecurityContext中”即可。

接下来,需要在csmall-product中:

  • 添加依赖项:spring-boot-starter-securityjjwtfastjson
  • 将配置文件中的JWT相关配置复制过来
    • 有效期是可选的
  • LoginPrincipal复制过来
  • ServiceCode的代码同步(csmall-passport中添加了更多枚举)
  • JwtAuthorizationFilter复制过来
  • SecurityConfiguration复制过来
    • 删除白名单中的/admins/login
    • 删除PasswordEncoder@Bean方法
    • 删除AuthenticationManager@Bean方法

以上就已经完成了基于Spring Security与JWT实现的单点登录

另外,其实也可以使用共享Session的做法实现类似的效果,但是,共享Session无法解决“Session不可以设置较长的有效期”的问题。

关于单点登录

普通登录的问题

SSO是单点登录的缩写:SSO(Single Sign On)

微服务架构下,要解决单点登录实现会话保持的问题

首先我们分析一下普通登录和微服务登录的区别

先是单体项目登录之后的操作流程

主要依靠服务器的session保存用户信息

客户端发请求时,将sessionid同时发往服务器,根据sessionid就能确认用户身份

分布式或微服务项目中,服务器不再只有一个

那么就会出现下面的问题

上面的图片,表示我们在微服务系统中登录时遇到的问题

我们在用户模块中登录,只是将用户信息保存在用户模块的session中

而这个session不会和其他模块共享

所以在我们访问购物车模块或其他模块时,通过sessionid并不能获得在用户模块中登录成功的信息

这样就丢失了用户信息,不能完成业务,会话保持就失败了

市面上现在大多使用JWT来实现微服务架构下的会话保持

也就是在一个服务器上登录成功后,微服务的其他模块也能识别用户的登录信息

这个技术就是单点登录

单点登录解决方案

Session共享

Session共享是能够实现单点登录效果的

这种方式的核心思想是将用户的登录信息共享给其他模块

适用于小型的,用户量不大的微服务项目

上面这个结构实现起来比较简单,Spring有框架直接支持,添加配置和依赖即可实现单点登录

这样就能将登录成功的用户信息共享给Redis

其他模块根据sessionId获得Redis中保存的用户信息即可

  • 这样做最大的缺点就是内存严重冗余,不适合大量用户的微服务项目

JWT单点登录

Json Web Token(令牌)

这种登录方式,最大的优点就是不占用内存

生成的JWT由客户端自己保存,不占用服务器内存

在需要表明自己用户身份\信息时,将JWT信息保存到请求头中发送请求即可

Jwt登录流程图

关于Spring Security框架 关于单点登录sso相关推荐

  1. 使用Spring Security OAuth2实现单点登录(SSO)系统

    一.单点登录SSO介绍   目前每家企业或者平台都存在不止一套系统,由于历史原因每套系统采购于不同厂商,所以系统间都是相互独立的,都有自己的用户鉴权认证体系,当用户进行登录系统时,不得不记住每套系统的 ...

  2. springBoot整合spring security+JWT实现单点登录与权限管理前后端分离

    在前一篇文章当中,我们介绍了springBoot整合spring security单体应用版,在这篇文章当中,我将介绍springBoot整合spring secury+JWT实现单点登录与权限管理. ...

  3. springBoot整合spring security+JWT实现单点登录与权限管理前后端分离--筑基中期

    写在前面 在前一篇文章当中,我们介绍了springBoot整合spring security单体应用版,在这篇文章当中,我将介绍springBoot整合spring secury+JWT实现单点登录与 ...

  4. Java Spring Cloud XII 之 单点登录

    Java Spring Cloud XII 之 单点登录 单点登录 1.用户\角色\权限 用户是一个基本的单位 我们登录时都是在登录用户的 我们再登录后需要明确这个用户具有哪些角色 用户和角色的关系是 ...

  5. jwt单点登录_单点登录SSO技术选型

    一些人存在的意义总归是让另一些人成长,然后消失. --刘同<谁的青春不迷茫> 1.单点登录是什么? 单点登录主要用于多系统集成,即在多个系统中,用户只需要到一个中央服务器登录一次即可访问这 ...

  6. CAS解决单点登录SSO

    关于CAS很多的原理和基础的配置启动,网上是很多的,我更多是结合我的实践和心得.需要了解CAS的原理,认证协议,认证流程,可以参考以下文章. 让CAS支持客户端自定义登陆页面--客户端篇 CAS原理与 ...

  7. 【Spring Security】Spring Security框架详解

    文章目录 前言 一.框架概述 Spring Security的架构 Spring Security的主要特点 二.认证 HTTP Basic认证 表单登录 OpenID Connect 三.授权 基于 ...

  8. OAuth2 实现单点登录 SSO

    转载自  OAuth2 实现单点登录 SSO 1. 前言 技术这东西吧,看别人写的好像很简单似的,到自己去写的时候就各种问题,"一看就会,一做就错".网上关于实现SSO的文章一大堆 ...

  9. OAuth2实现单点登录SSO

    本文转载自:https://www.cnblogs.com/cjsblog/p/10548022.html OAuth2实现单点登录SSO 1.  前言 技术这东西吧,看别人写的好像很简单似的,到自己 ...

最新文章

  1. 2022-2028年中国钢轨探伤车行业市场研究及前瞻分析报告
  2. 百度语音合成 js html,Node.js结合百度TTS接口实现文字转语音功能
  3. 日期格式转换 java 2016-09-03T00:00:00.000+08:00
  4. python写文件读文件-python(文件读写)
  5. activeform表单中的旧数据怎么显示_三分钟为你细数 Vue el-form 表单校验的坑点
  6. e-mobile帐号状态存在异常_Java 常见异常种类
  7. /frameworks/support
  8. 知识点篇:2)产品结构设计目标的分类
  9. layui 弹窗自适应高度_layui弹框自适应高度
  10. 大漠插件7.2137
  11. mac 清理微信缓存文件
  12. c语言怎么写最小公倍数的函数,c语言最小公倍数怎么求
  13. 飞塔防火墙命令行终端修改输出长度
  14. python:max函数
  15. 龙芯3A4000处理器解读①
  16. r语言 svycoxph_R语言-Cox比例风险模型
  17. 计算机视觉(相机标定)-1.1-针孔摄像机透镜
  18. ABM410-ASEMI贴片整流桥ABM410
  19. 【计算机网络】——体系结构
  20. 【论文分享】异构图神经网络域名检测方法GAMD:Attributed Heterogeneous Graph Neural Network for Malicious Domain Detection

热门文章

  1. python数字1 3怎么表示_python数字1-3
  2. linux模拟usb发包,Linux下USB模拟ps2鼠标驱动
  3. obs多推流地址_微信小程序直播电脑端OBS推流直播教程
  4. 拆解「千言数据集:文本相似度」竞赛第一背后的故事
  5. Python报错不要慌,这三个关键词帮你解决问题!
  6. java计算机毕业设计快滴预约平台源码+mysql数据库+系统+lw文档+部署
  7. Oracle操作语句(PL/SQL)创建表空间:第 1 行出现错误: ORA-01119: 创建数据库文件时出错 ORA-27040: 文件创建错误, 无法创建文件OSD-04002: 无法打开文件
  8. 华为数通HCIA学习笔记之OSI参考模型TCP/IP模型
  9. STM32F4xx时钟配置的三种方法
  10. 【android学习】记录应用内存优化