以前对于安全框架不太熟悉,无论是Shiro还是Spring Security。现在Spring用的比较多,所以想好好学一下Spring Security。此篇博客算是在别人那里学习的基础上写的,目的是了解SpringSecurity、Oath2、JWT、SpringSecurity + Oath2、SpringSecurity + Oauth2 +JWT以及SpringSecurity + Oath2 + JWT整合SSO。
安全框架就是解决系统安全的框架。如果没有安全框架,我们需要手动的处理每个资源的访问控制,这是非常麻烦的。使用了安全框架,我们可以通过配置的方式实现对资源的访问限制。
Spring Security是一个高度自定义的安全框架。利用Spring IOC、DI和AOP的功能,为系统提供了声明式安全访问控制功能,减少了为系统安全而编写大量重复代码的工作。使用Spring Security的原因很多,但大部分都是发现了Java EE的Servlet规范或EJB规范中的安全功能缺乏典型的企业级应用场景,同时认识到他们在WAR或EAR级别无法移植。因此如果更换服务器环境,还有大量工作去重写配置应用程序。使用Spring Security解决了这些问题,也为你提供许多其他有用的、可定制的安全功能。应用程序的两个主要区域是认证和授权(访问控制)。这两点也是Spring Security重要的核心功能。认证是建立一个他声明的主体的过程,一个主体一般指用户,设备或者一些可以在你的应用程序中执行动作的其他系统,简单来说就是系统认为用户是否能登录。授权指确定一个主体是否允许在你的应用程序中执行一个动作的过程,简单来说就是系统判断用户是否有权限去执行某些操作。
(1)创建工程
创建一个springboot工程,springsecurity-demo。
(2)pom依赖
pom依赖如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.6.RELEASE</version>
<relativePath />
</parent>
<groupId>com.ycz</groupId>
<artifactId>spring-security-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springsecurity-demo</name>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- spring security依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- web模块 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 测试包依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- security测试包 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
(3)yml配置
application.yml中只有一个端口配置。
server:
port: 8888
(4)启动类
@SpringBootApplication
public class SpringsecurityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringsecurityDemoApplication.class, args);
}
}
(5)登录页面和主页面
登录的简单页面login.html如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录界面</title>
</head>
<body>
<form action="/login" method="post">
用户名:<input type="text" name="username" /><br>
密码:<input type="password" name="password"/><br>
<input type="submit" value="登录">
</form>
</body>
</html>
主页面main.html如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>主页面</title>
</head>
<body bgcolor="pink">
<h2 align="center">欢迎来到主页面!</h2>
</body>
</html>
(6)控制器
创建controller包,控制器放在controller包,内容如下:
@Controller
public class LoginController {
/*
* 登录
*/
@RequestMapping("/login")
public String login() {
return "redirect:main.html";
}
}
启动项目,访问localhost:8888/login.html
进入了SpringSecurity的内置页面,需要验证,来到控制台:
这个是验证密码,用户名是user,每次启动都会生成不一样的密码,注意一下。使用用户名和控制台的密码登录:
验证成功后来到了登录页面。
这个是Spring Security提供的接口,源码如下:
用户的登录是访问这个接口的唯一方法loadUserByUsername的,这个方法接收一个参数,就是用户名,如果没有,会抛异常。如果有,返回UserDetails。
UserDetails也是Spring Security提供的一个接口,源码如下:
这个接口有7个抽象方法,值得注意的前3个方法,第1个方法是获取权限的,第2个方法获取密码,第3个方法获取用户名。
UserDetails接口有一个实现类User,部分源码如下:
有一个构造方法:
这个构造方法有3个参数:用户名、密码、权限。然后里面调用了重载的另一个构造方法:
这个重载的构造方法有7个参数,除了用户名、密码、权限外,还有账号是否启用、是否过期、是否锁定等。
这3个东西的位置如下:
PasswordEncoder是Spring Security提供的一个接口,称它为密码解析器,这个接口主要是处理密码的。源码如下:
接口提供3个方法,第一个方法是对明文密码进行加密的,返回一个密文。第二个方法是匹配明文密码和密文,返回布尔值。第三个方法是对密文进行二次加密,这个方法是默认的。
PasswordEncoder接口有很多实现类,其中最主要的是官方推荐的BCryptPasswordEncoder类,平时使用的最多的就是这个密码解析器。BCryptPasswordEncoder是对bcrypt强散列方法的具体实现,是基于hash算法的单向加密。可以通过strength来控制强度,默认是10。
源码如下:
encode方法是对明文密码进行加密,原理是使用一个随机生成的salt,用明文密码加上这个salt来一起进行加密,返回密文,由于这个salt每次生成的都不一样,所以即使明文密码一样,最后加密出来的密文是不一样的,这样保证了用户密码的安全。
matchs方法是用来匹配明文密码和密文的,最终结果用布尔值返回。
测试加密和匹配:
// 测试BCryptPasswordEncoder的加密和匹配方法
@Test
public void test1() {
String password = "ycz123456";// 密码
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
for (int i = 1; i <= 5; i++) {
// 加密明文密码,返回密文
String encoder = passwordEncoder.encode(password);
// 明文和密文进行匹配
boolean bool = passwordEncoder.matches(password, encoder);
System.out.println(encoder + ":是否匹配?" + bool);
}
可以看到,一样的密码,5次加密后的密文全都不一样,但是全都能匹配上。
(1)配置类
当我们自定义登录逻辑时,需要用到UserDetailsService和PasswordEncoder,Spring Security要求自定义登录逻辑时容器内必须要有PasswordEncoder实例,不能new出来,因此需要一个配置类来向容器中注入。
创建config包,包下定义SecurityConfig配置类:
/*
* Spring Security配置
*/
@Configuration
public class SecurityConfig {
// 向容器中注入PasswordEncoder实例
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
(2)UserDetailsService的实现类
需要有一个类来实现UserDetailsService接口,如下:
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
/*
* 重写loadUserByUsername方法
*/
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
//实际是根据用户名去数据库查,这里就直接用静态数据了
if(!username.equals("ycz")) {
throw new UsernameNotFoundException("用户名不存在!");
}
//比较密码,匹配成功会返回UserDetails,实际上也会去数据库查
String password = passwordEncoder.encode("ycz123456");
User user = new User(username,password,AuthorityUtils.
commaSeparatedStringToAuthorityList("ycz,admin"));
return user;
}
}
启动项目,使用这里的账号和密码来通过Spring Security的验证:
通过了Spring Security的验证,来到了登录页面。
Spring Security提供了登录页面,就是需要验证的那个页面。但是一般在实际项目中会用自己定义好的登录页面,如果想用自己定义好的登录页面,比如这里的login.html,只需要修改配置类即可。
修改后的配置类如下:
/*
* Spring Security配置
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Override
protected void configure(HttpSecurity http) throws Exception {
//表单提交
http.formLogin()
.loginPage("/login.html")//自定义登录页面
//必须和表单提交的接口路径一致,会去执行自定义登录逻辑
.loginProcessingUrl("/login")
//登录成功后跳转到的页面,只接受post请求
.successForwardUrl("/toMain");
//授权
http.authorizeRequests()
.antMatchers("/login.html").permitAll()//放行的路径
.anyRequest().authenticated();//除了放行路径,其他路径都需要授权
//关闭csrf防护
http.csrf().disable();
}
// 向容器中注入PasswordEncoder实例
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
LoginController修改如下:
@Controller
public class LoginController {
/*
* 登录
*/
@RequestMapping("/toMain")
public String main() {
return "redirect:main.html";
}
}
启动项目,访问http://localhost:8888/login.html:
输入用户名和密码进行登录:
登录成功。来到了主页面。
然后LoginController控制器添加:
/*
* 错误跳转
*/
@RequestMapping("/toError")
public String error() {
return "redirect:error.html";
}
自定义的错误页面error.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>错误页面</title>
</head>
<body>
<h2 style="color:red" align="center">登录失败,请重新登录!</h2>
<a href="/login.html">点击跳转</a>
</body>
</html>
启动项目,访问http://localhost:8888/login.html,输入错误的用户名:
登录:
跳到了自定义的错误页面,点击跳转:
回到了登录页面。
前面写的登录页面的表单:
标记的地方是必须这样写的,也就是固定,提交的地址必须是login,提交方法必须是post,用户名的参数必须是username,密码的参数必须是password。原因是Spring Security定义了一个UsernamePasswordAuthenticationFilter拦截器,拦截器里面已经定死了。
UsernamePasswordAuthenticationFilter拦截器源码如下:
上面已经定死了。
如果不是post请求提交,直接会抛异常。
获取用户名参数和密码参数时按照定义的username和password参数来获取,如果表单里面的和这里不一样,是获取不到的。
但是它提供了方法可以修改用户名和密码的参数名:
通过这两个方法可以修改参数名称。
然后表单的参数名只需和配置类里设置的一样就可以了:
启动项目,访问登录页面:
输入用户名密码登录:
登录成功,说明配置起作用了。
前面登录成功后是跳转到新页面的,如下:
点进去这个方法:
点进去ForwardAuthenticationSuccessHandler:
可以看到,底层就是一个forward跳转,我们知道forward跳转是无法跳到应用之外的页面的, 现在进行测试,修改配置类中登录成功后跳转的页面:
看能否跳到B站主页。启动项目,访问登录页面:
输入用户名、密码登录:
无法跳转,因为底层的源码是forward跳转,无法跳到应用之外的页面。那么怎么样跳到应用之外的页面呢?可以通过重定向来实现。观察源码:
发现是通过标记的这个方法来实现成功跳转的,那么我们可以自己实现AuthenticationSuccessHandler接口,自己定义这个方法的逻辑,里面不用跳转,使用重定向。
创建新的包handler,包下创建MyAuthenticationSuccessHandler,内容如下:
/*
* 自定义登录成功后的处理器
*/
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private String url;
// 构造方法
public MyAuthenticationSuccessHandler(String url) {
this.url = url;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 获取用户
User user = (User) authentication.getPrincipal();
// 打印用户名
System.out.println(user.getUsername());
// 密码,出于安全考虑,Spring Security这里会返回一个Null
System.out.println(user.getPassword());
// 权限
System.out.println(user.getAuthorities());
// 这里使用跳转
response.sendRedirect(url);
}
}
然后配置类里修改如下:
启动项目,访问登录页面,输入用户名密码进行登录:
跳过来了,再看控制台:
也打印出内容了。可以看一下Authentication的源码:
这个接口是继承了Principal接口,并且有一个这样的方法:
刚才调用的就是这个getPrincipal方法,返回一个Object类型对象,里面包含用户的一些信息。
类似成功登录的处理器,先看源码:
本质上是调用了ForwardAuthenticationFailureHandler处理器的onAuthenticationFailure方法来实现跳转。我们也可以自己实现AuthenticationFailureHandler接口,自定义失败跳转逻辑。
在handler包下创建MyAuthenticationFailureHandler,内容如下:
/*
* 自定义失败登录跳转处理器
*/
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
private String url;
// 构造方法
public MyAuthenticationFailureHandler(String url) {
this.url = url;
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
// 失败登录后使用重定向
response.sendRedirect(url);
}
}
然后修改配置类:
启动项目,访问登录页面,使用错误的用户名密码进行登录:
可以跳转。事实上在实际开发中基本都是前后端分离的,使用的都是这两种处理器的方式来进行跳转。
(1)anyRequest
这个是所有请求,Spring Security的要求是这个必须要放在最后面,如下:
可以理解为拦截器,放行的路径放在前面,从前往后执行,除了放行路径之外的其他路径都需要进行认证才能访问。
(2)antMatchers
这个是放行的路径,放行的路径不需要进行Spring Security即可访问,比如项目的一些css、js、图片等静态资源全部需要放行,如下:
这3个文件夹下的内容都要放行,配置如下:
启动项目,访问images目录下的一张图片:
成功访问。只要满足ant匹配表达式的路径都会放行,如下:
启动项目,访问time.jpg图片:
这个被拦截了。
(3)regexMatchers
可以使用正则表达式来匹配放行路径,如下:
访问:
还可以规定请求方式:
在控制器里添加:
放行路径:
这里只放行post请求的test路径,访问:
被拦截了,因为/test是get请求的,修改放行路径:
这里改为GET请求,再访问:
请求成功,说明放行了。
(4)mvcMatchers
一般和servletPath一起用,相当于加应用前缀。在application.yml中添加:
修改配置类:
启动项目,访问:
加前缀再访问:
这种方式其实完全可以用antMatchers替代,如下:
再访问:
这里的permitAll方法和authenticated方法,看源码:
可以看到有6种属性,每种属性都对应一个方法,permitAll是允许所有,denyAll是拒绝所有,anonymous是允许匿名的,authenticated是需要认证,fullyAuthenticated是需要完整的认证,rememberMe是记住,比如7天免登录这种。这6个方法无法单独使用,需要配合antMatchers等方法一起使用。
除了内置权限控制。Spring Security中还支持很多其他权限控制。这些方法一般都用于用户已经被认证后,判断用户是否具有特定的要求。
(1)基于权限判断
判断用户是否具有特定的权限,用户的权限是在自定义登录逻辑中创建User对象时指定的。如下:
这里直接指定了两个静态的数据作为权限。
主页面中加一个跳转:
main1页面如下:
修改yml配置文件:
修改Spring Security配置文件:
拥有ycz这个权限才能访问main1.html这个页面。
启动,登录:
点击链接:
可以成功访问main1.html页面。
修改配置文件:
再启动,点击主页面中的链接:
访问不了,因为当前用户并没有yan这个权限。也可以指定多个权限,如下:
只要用户拥有其中指定的任意一个权限,就能够访问。
(2)基于角色判断
如果用户具备给定角色就允许访问。否则出现 403。参数取值来源于自定义登录逻辑 UserDetailsService实现类中创建 User 对象时给User赋予的授权。
必须以ROLE_开头,这是固定格式,后面接角色名称。
修改配置文件:
启动工程,主页面中点击链接:
可以访问,如果用户没有该角色,就无法访问,类似权限的控制。
(3)基于IP地址控制
只允许指定的IP地址访问,其他的IP拒绝访问,如下:
启动,点击主页面中的链接:
访问成功。
使用Spring Security时经常会看见40(无权限),默认情况下显示的效果如下:
报403页面是因为权限不足。源码:
可以自定义类实现这个接口,并且修改规则。
在handler包中新建MyAccessDeniedHandler类,如下:
/*
* 自定义403处理
*/
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
// 设置响应头
response.setHeader("Content-Type", "application/json;charset=utf-8");
PrintWriter printWriter = response.getWriter();
// 向页面中写内容
printWriter.write("{\"status\":\"error\",\"msg\":\"权限不足,请联系管理员!\"}");
printWriter.flush();
printWriter.close();
}
}
修改配置类:
标记的是新添加的。启动工程,点击主页面中的链接:
是自定义的json串,说明设置起了作用。
先看一下之前的基于角色、权限、IP地址以及内置的访问控制的底层:
可以看到,权限判断实际上底层实现都是调用access(表达式)。
以下为常见的内置表达式:
之前的权限控制其实使用access表达式也可以达到相同的控制效果,比如下面:
可以修改成下面这样:
启动工程测试:
可以访问。
在实际项目中很有可能出现需要自己自定义逻辑的情况。比如判断登录用户是否具有访问当前URL权限。
新建接口:
public interface MyService {
//判断是否有权限
boolean hasPermission(HttpServletRequest request,Authentication authentication);
}
接口的实现类:
@Component
public class MyServiceImpl implements MyService {
@Override
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
// 获取当前主体
Object obj = authentication.getPrincipal();
// 如果属于UserDetails
if (obj instanceof UserDetails) {
// 下转型
UserDetails userDetails = (UserDetails) obj;
// 获取主体的所有权限
Collection<? extends GrantedAuthority> authorities = userDetails
.getAuthorities();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(
request.getRequestURI());
boolean res = authorities.contains(simpleGrantedAuthority);
return res;
}
return false;
}
}
修改配置类:
启动项目,登录:
没有权限,原因是登录成功后的url应该是/main.html
,但是在这里并没有:
需要添加进去:
启动,再登录:
可以登录进去。
在Spring Security中提供了一些访问控制的注解。这些注解都是默认是都不可用的,需要通过@EnableGlobalMethodSecurity进行开启后使用。如果设置的条件允许,程序正常执行,如果不允许会报500错误。
这些注解可以写到Service接口或方法上,也可以写Controller或Controller的方法上。通常情况下都是写在控制器方法上的,控制接口URL是否允许被访问。
@Secured是专门用于判断是否具有角色的,能写在方法或类上,参数要以 ROLE_开头。源码如下:
(1)开启注解
在启动类或者配置类等能够扫描的类上添加,这里直接在配置类上添加:
说明一下,这个注解里面的所有值都是默认为false的,要用的话必须手动改为true。源码如下:
(2)在Controller的方法上添加注解
配置类需要修改一下,将之前通过配置判断权限都取消,要不然可能出问题:
启动项目,测试登录:
登录成功。然后将注解中的角色改成错的,再登录:
控制台报错:
因为角色不对,方法是受保护的,不允许访问。可以看出,只有角色正确才能够访问对应的方法。@Secured注解和hasRole这个方法的作用是一样的,通过角色判断。
@PreAuthorize和@PostAuthorize都是方法或类级别注解。源码如下:
这两个注解的区别如下:
实际开发中基本上用的都是@PreAuthorize注解,在执行之前进行判断,判断是否具有操作方法的权限,如果是之后再判断,那就没什么意义了,所以@PostAuthorize注解用的不多。
(1)开启注解
和上面的类似,这里也是在配置类上开启:
(2)在Controller的方法上添加注解
在控制器方法上添加@PreAuthorize,参数可以是任何 access()支持的表达式。
这里是对角色判断,并且加了前缀ROLE_的,先测试错误的角色:
再测试正确的角色:
可以访问。然后测试不加前缀:
还是以访问,说明一个问题:如果是在@PreAuthorize注解中的话,可以加ROLE_前缀,也以不加,但是在配置中一定不能加前缀。
再测试通过权限访问:
可以访问,那么发现@PreAuthorize注解对角色和权限控制都行,事实上,只要是access()方法里面的,这个注解都支持。
Spring Security中Remember Me为“记住我”功能,用户只需要在登录时添加remember-me复选框,取值为true。Spring Security会自动把用户信息存储到数据源中,以后就可以不登录进行访问。
(1)前端
会用到数据库,pom依赖中添加MySQL驱动包和spring mybatis的整合包:
<!-- mysql驱动包 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.11</version>
</dependency>
<!-- mybatis和spring的整合包 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.1</version>
</dependency>
(3)yml中添加数据源
application中添加数据源信息:
spring:
datasource:
url: jdbc:mysql://rm-m5e130nm7h37n6v982o.mysql.rds.aliyuncs.com:3306/security?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8
username: xxxxxxx
password: xxxxxxx
driverClassName: com.mysql.cj.jdbc.Driver
需要在Mysql中创建一个新的数据库security。
(4)配置类
在config包下新建一个RememberMeConfig,如下:
/*
* 记住我功能配置类
*/
@Configuration
public class RememberMeConfig {
// 注入数据源
@Autowired
private DataSource dataSource;
// 注入Bean
@Bean
public PersistentTokenRepository getPersistentTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepositoryImpl = new JdbcTokenRepositoryImpl();
// 设置数据源
jdbcTokenRepositoryImpl.setDataSource(dataSource);
// 自动建表,第一次运行设为true,以后都设为false
jdbcTokenRepositoryImpl.setCreateTableOnStartup(true);
return jdbcTokenRepositoryImpl;
}
}
(5)修改SecurityConfig配置
在SecurityConfig配置类中添加:
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
@Autowired
private PersistentTokenRepository persistentTokenRepository;
//记住我功能
http.rememberMe()
//参数名,和表单中的一样
.rememberMeParameter("rememberMe")
//持久层对象
.tokenRepository(persistentTokenRepository)
//登录逻辑设置
.userDetailsService(userDetailsServiceImpl)
//失效时间,默认为两周,这里设为60秒
.tokenValiditySeconds(60);
(6)测试
自动建了一张表,表中无数据。
访问登录页面进行登录,勾选复选框:
登录成功,查看数据库中的表:
表中自动添加了1条数据。
然后关闭浏览器,再打开,直接访问http://localhost:8888/main.html
不用登录,可以访问。等1分钟后,再关闭浏览器,再打开,直接访问主页面:
失效了,再次登录,勾选复选框:
登录成功,再查看数据表:
新增了一条数据,也就是说,用户每次登录,只要勾选了记住我复选框,登录成功后都会往表中写入一条数据,表的时间字段是用来和用户再次登录的时间戳进行对比的,如果时间差小于1分钟,就是免登录,如果时间差大于1分钟,记住我功能就会失效,需要用户重新进行登录。
Spring Security可以在一些视图技术中进行控制显示效果。例如: JSP或Thymeleaf 。在非前后端分离且使用Spring Boot的项目中多使用Thymeleaf作为视图展示技术。Thymeleaf对Spring Security的支持都放在thymeleaf-extras-springsecurityX中,目前最新版本为5。
(1)pom依赖
在pom文件中添加:
<!-- thymeleaf springsecurity5整合依赖 -->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<!-- thymeleaf依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
(2)前端页面
在templates目录下创建show.html,内容如下:
<!DOCTYPE html>
<html lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>展示页面</title>
</head>
<body bgcolor="orange">
登录账号:<span sec:authentication="name"></span><br/>
登录账号:<span sec:authentication="principal.username"></span><br/>
凭证:<span sec:authentication="credentials"></span><br/>
权限和角色:<span sec:authentication="authorities"></span><br/>
客户端地址:<span sec:authentication="details.remoteAddress"></span><br/>
sessionId:<span sec:authentication="details.sessionId"></span><br/>
</body>
</html>
在html页面中引入thymeleaf命名空间和security命名空间:
可以在html页面中通过sec:authentication=""获取UsernamePasswordAuthenticationToken中所有getXXX的内容,包含父类中的getXXX的内容。
根据源码得出下面属性:
(3)controller
在LoginController中添加如下方法:
@RequestMapping("/show")
public String show() {
return "show";
}
启动工程,登录,然后访问:http://localhost:8888/show。
(4)权限判断
然后在show.html中添加以下内容:
show.html完整内容如下:
<!DOCTYPE html>
<html lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>展示页面</title>
</head>
<body bgcolor="orange">
<h4>
登录账号:<span sec:authentication="name"></span><br/>
登录账号:<span sec:authentication="principal.username"></span><br/>
凭证:<span sec:authentication="credentials"></span><br/>
权限和角色:<span sec:authentication="authorities"></span><br/>
客户端地址:<span sec:authentication="details.remoteAddress"></span><br/>
sessionId:<span sec:authentication="details.sessionId"></span><br/>
通过权限判断:
<button sec:authorize="hasAuthority('/insert')">新增</button>
<button sec:authorize="hasAuthority('/delete')">删除</button>
<button sec:authorize="hasAuthority('/update')">修改</button>
<button sec:authorize="hasAuthority('/select')">查看</button>
<br/>
通过角色判断:
<button sec:authorize="hasRole('ycz')">新增</button>
<button sec:authorize="hasRole('ycz')">删除</button>
<button sec:authorize="hasRole('ycz')">修改</button>
<button sec:authorize="hasRole('ycz')">查看</button>
</h4>
</body>
</html>
启动工程,登录,再访问http://localhost:8888/show。
按照权限判断的话用户只拥有添加和删除权限,上面确实只显示了这两个按钮。
按照角色判断的话,应该四个按钮都显示,结果也是这样。
用户只需要向Spring Security项目中发送/logout 退出请求即可。
在main.html中添加退出链接:
修改配置类:
启动,登录到主页面:
点击退出登录的链接:
成功退出,来到了登录页面。
按照源码的内容我们可以得出结论:退出的时候,session失效,并且将认证置为空,清空了上下文。
前面其实已经配置过了:
如果没有这行代码会导致用户无法被认证。这行代码的含义是:关闭csrf防护。
CSRF(Cross-site request forgery)跨站请求伪造,也被称为“OneClick Attack” 或者 Session Riding。通过伪造用户请求访问受信任站点的非法请求访问。
跨域:只要网络协议,ip地址,端口中任何一个不相同就是跨域请求。
客户端与服务进行交互时,由于http协议本身是无状态协议,所以引入了cookie进行记录客户端身份。在cookie中会存放session id用来识别客户端身份的。在跨域的情况下,session id 可能被第三方恶意劫持,通过这个session id向服务端发起请求时,服务端会认为这个请求是合法的,可能发生很多意想不到的事情。
从Spring Security4开始CSRF防护默认开启,默认会拦截请求。CSRF为了保证不是其他第三方网站访问,要求访问时携带参数名为 _csrf
值为token(token 在服务端产生)的内容,如果token和服务端的token匹配成功,则正常访问。下面将进行测试。
(1)前端
在templates目录下创建login.html,注意和static下的区分,内容如下:
<!DOCTYPE html>
<html lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录界面</title>
</head>
<body>
<form action="/login" method="post">
用户名:<input type="text" name="loginName" /><br>
密码:<input type="password" name="loginpwd"/><br>
<input type="submit" value="登录">
</form>
</body>
</html>
(2)controller
在LoginController中添加:
@RequestMapping("/showLogin")
public String showLogin() {
return "login";
}
(3)配置类修改
标记的地方需要修改成showLogin。
然后csrf需要开启:
这行代码注释掉。然后启动工程测试:
以这个url访问登录页面,注意,这个登录页面是templates下的模板,不是原来的static目录下的login.html。输入用户名密码,登录:
因为开启了CSRF,所以是认证不成功的。
再启动工程测试:
登录成功。打开浏览器的调试工具:
可以看到表单提交的时候提交了csrf的值,说明在页面中获取到了。csrf在实际开发中非常重要,对于用户的账号安全起着至关重要的作用,可以防止用户的sessionId被第三方劫持,因此csrf都不会关闭,会一直保持开启状态。
OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。与以往的授权方式不同之处是OAUTH的授权不会使第三方触及到用户的帐号信息(如用户名与密码),即第三方无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此OAUTH是安全的。oAuth是Open Authorization的简写。它是一种开放的协议,第三方可以使用OAUTH认证服务。
第三方认证技术方案最主要是解决认证协议的通用标准问题,因为要实现跨系统认证,各系统之间要遵循一定的接口协议。任何第三方都可以使用OAUTH认证服务,任何服务提供商都可以实现自身的OAUTH认证服务,因而OAUTH是开放的。业界提供了OAUTH的多种实现如PHP、JavaScript,Java,Ruby等各种语言开发包,大大节约了程序员的时间,因而OAUTH是简易的。互联网很多服务如Open API,很多大公司如Google,Yahoo,Microsoft等都提供了OAUTH认证服务,这些都足以说明OAUTH标准逐渐成为开放资源授权的标准。
OAuth2.0是OAuth协议的延续版本,但不向前兼容OAuth 1.0(即完全废止了OAuth1.0)。 OAuth 2.0关注客户端开发者的简易性。要么通过组织在资源拥有者和HTTP服务商之间的被批准的交互动作代表用户,要么允许第三方应用代表用户获得访问的权限。同时为Web应用,桌面应用和手机,和起居室设备提供专门的认证流程。2012年10月,OAuth 2.0协议正式发布为RFC 6749 。因为1.0版本过于复杂,到目前已经完成废止了,现在2.0版本已得到广泛应用。
以京东网站使用微信认证的过程来说明OAuth2.0认证:
资源服务器和认证服务器可以是一个服务也可以分开的服务,如果是分开的服务,资源服务器通常要请求认证服务器来校验令牌的合法性。
后面这4步用户其实是看不到的,用户只用扫码并在微信同意授权,然后就登录成功,后面的这几步是由应用来完成的。
认证流程中的各个角色:
客户端
本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:Android客户端、Web客户端(浏览器端)、微信客户端等。
资源拥有者
通常为用户,也可以是应用程序,即该资源的拥有者。
授权服务器(也称认证服务器)
用来对资源拥有的身份进行认证、对访问资源进行授权。客户端要想访问资源需要通过认证服务器由资源拥有者授权后方可访问。
资源服务器
存储资源的服务器,比如,网站用户管理服务器存储了网站用户信息,网站相册服务器存储了用户的相册信息,微信的资源服务存储了微信的用户信息等。客户端最终访问资源服务器获取资源信息。
一些专有名词:
令牌类型:
优点:
缺点:
OAuth2.0有4种授权模式:授权码模式、简化授权模式、密码模式、客户端模式。使用的最多的是授权码模式,这种在实际项目中用的最多,也最安全。
这种模式是最复杂也是最安全的,用的最多的,重点掌握这种模式。
流程图如下:
这种用的比较少。
这种用的也很少。
比如Docker用的就是这种。
刷新令牌(Refresh Token)
刷新令牌Refresh Token是用于Access Token访问令牌过期的时候,不用从头开始认证,直接拿着Refresh Token去认证服务,认证服务直接颁发一个新的访问令牌Access Token给客户端,客户端带着新的访问令牌访问资源服务器的资源。
(2)pom依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.2.RELEASE</version>
<relativePath />
</parent>
<groupId>com.ycz</groupId>
<artifactId>spring-security-oauth2-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springsecurity-oauth2-demo</name>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR2</spring-cloud.version>
</properties>
<dependencies>
<!-- spring cloud中的oauth2依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<!-- spring cloud中的security依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<!-- web模块 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 测试包 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<!-- 引入spring cloud -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${
spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
(3)pojo
在pojo包下自定一个实体类User,但是此类必实现UserDetails接口。如下:
/*
* 自定义User类,需实现UserDetails接口
*/
public class User implements UserDetails {
private String username;
private String password;
private List<GrantedAuthority> authorities;
// 构造方法
public User(String username, String password, List<GrantedAuthority> authorities) {
this.username = username;
this.password = password;
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
(4)Spring Security配置类
config包下创建SecurityConfig配置类,如下:
/*
* Spring Security配置类
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/oauth/**","/login/**","/logout/**").permitAll()//放行
.anyRequest().authenticated()//其他路径拦截
.and()
.formLogin().permitAll()//表单提交放行
.and()
.csrf().disable();//csrf关闭
}
// 注册PasswordEncoder
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
(5)自定义登录逻辑
service包下创建UserDetailsServiceImpl类,如下:
/*
* 自定义登录逻辑
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService{
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
// 密码加密
String password = passwordEncoder.encode("ycz123456");
//创建User用户,自定义的User
User user = new User(username,password,AuthorityUtils.
commaSeparatedStringToAuthorityList("ycz"));
return user;
}
}
(6)认证服务配置
在config包下创建认证服务的配置类AuthorizationServerConfig,如下:
/*
* 授权服务配置
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter{
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()//内存中
.withClient("client")//客户端ID
.secret(passwordEncoder.encode("ycz123456"))//秘钥
.redirectUris("http://www.baidu.com")//重定向到的地址
.scopes("all")//授权范围
.authorizedGrantTypes("authorization_code");//授权类型为授权码模式
}
}
(7)资源服务配置
在config包下创建资源服务的配置类
/*
* 资源服务配置
*/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter{
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.requestMatchers()
.antMatchers("/user/**");
}
}
(8)controller
在controller包下创建UserController类,如下:
@RestController
@RequestMapping("/user")
public class UserController {
@RequestMapping("/getCurrentUser")
public Object getCurrentUser(Authentication authentication) {
return authentication.getPrincipal();
}
}
(1)获取授权码
启动项目,访问:
http://localhost:9999/oauth/authorize?response_type=code&client_id=client&redirect_uri=http://www.baidu.com&scope=all
说明:
(2)获取令牌
因为要发送post请求,所以使用postman。
url:http://localhost:9999/oauth/token
左边的type选择Basic Auth,右边的用户名为客户端ID,密码是定义好的。
body里面选择表单,需要携带5个参数。参数说明如下:
发送请求,返回如下:
通行令牌access_token拿到了。如果授权码不对,是拿不到令牌的,如下:
只有授权码正确,服务端才会返回通行令牌,注意。
(3)获取资源服务器资源
需要携带通行令牌来获取。还是post请求:
http://localhost:9999/user/getCurrentUser
左边选择Bearer Token,右边Token里面填刚才返回的Token。
发送请求,返回结果如下:
获取到了用户信息。然后将令牌修改成错误的,再发送:
无效令牌,拒绝访问。只有携带正确的令牌才能访问资源。
直接在授权码模式的基础上进行修改了。
(1)修改SecurityConfig
直接在里面加:
//注册AuthenticationManager
@Bean
public AuthenticationManager getAuthenticationManager() throws Exception {
return super.authenticationManager();
}
(2)修改AuthorizationServerConfig
直接在里面加:
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsServiceImpl userDetailsServiceImpl;
//密码模式需要配置
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints)
throws Exception {
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsServiceImpl);
}
(1)获取Token令牌
启动项目,直接在postman中发送:
这里不变。
表单的参数如上,grant_type的值需要改成password,username的值随便,因为逻辑中没有规定用户名,实际上是需要规定的,password的值必须要写对。
(2)获取资源
现在直接可以携带令牌去访问资源:
发送,返回结果如下:
获取资源成功。
将token直接存在内存中,这在生产环境中是不合理的,下面将其改造成存储在Redis中。
(1)pom依赖
在pom中添加如下依赖:
<!-- redis依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- commons-pool2对象连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
(2)yml配置
在application.yml中添加:
spring:
redis:
host: localhost
port: 6379
password: 123456
(3)Redis配置类
在config包下创建RedisConfig配置类,如下:
/*
* Redis配置类
*/
public class RedisConfig {
@Autowired
private RedisConnectionFactory redisConnectionFactory;
//注册TokenStore
@Bean
public TokenStore reidsTokenStore() {
return new RedisTokenStore(redisConnectionFactory);
}
}
(4)修改授权服务配置
修改AuthorizationServerConfig,如下:
标记的地方是添加的。
(5)测试
启动工程,使用密码模式获取令牌:
发送,返回结果:
查看redis:
存的token和和返回的token是一致的。
这里存的是客户端ID。
这里存的是用户名。
HTTP Basic Auth简单点说明就是每次请求API时都提供用户的username和password,简言之,Basic Auth是配合RESTful API 使用的最简单的认证方式,只需提供用户名密码即可,但由于有把用户名密码暴露给第三方客户端的风险,在生产环境下被使用的越来越少。因此,在开发对外开放的RESTful API时,尽量避免采用HTTP Basic Auth。
Cookie认证机制就是为一次请求认证在服务端创建一个Session对象,同时在客户端的浏览器端创建了一个Cookie对象;通过客户端带上来Cookie对象来与服务器端的session对象匹配来实现状态管理的。默认的,当我们关闭浏览器的时候,cookie会被删除。但可以通过修改cookie expire time使cookie在一定时间内有效。
OAuth(开放授权,Open Authorization)是一个开放的授权标准,允许用户让第三方应用访问该用户在某一web服务上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。如网站通过微信、微博登录等,主要用于第三方登录。
OAuth允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的第三方系统(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。
这种基于OAuth的认证机制适用于个人消费者类的互联网产品,如社交类APP等应用,但是不太适合拥有自有认证权限管理的企业应用。缺点是过重。
这种方式比第一种方式更安全,比第二种方式更节约服务器资源,比第三种方式更加轻量。
Token机制相对于Cookie机制的优点如下:
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。
优点:
缺点:
JWT令牌较长,占存储空间比较大。
一个JWT实际上就是一个字符串,它由三部分组成:头部、负载和签名。
(1)头部(Header)
第一部分是头部,头部用于描述关于该JWT的最基本的信息,例如其类型(即JWT)以及签名所用的算法(如HMAC SHA256或RSA)等。这也可以被表示成一个JSON对象。
说明:
我们对头部的json字符串进行BASE64编码,编码后的字符串如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Base64是一种基于64个可打印字符来表示二进制数据的表示方法。由于2的6次方等于64,所以每6个比特为一个单元,对应某个可打印字符。三个字节有24个比特,对应于4个Base64单元,即3个字节需要用4个可打印字符来表示。JDK 中提供了非常方便的 BASE64Encoder 和 BASE64Decoder ,用它们可以非常方便的完成基于 BASE64 的编码和解码。
(2)负载(Payload)
第二部分是负载,就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分,如下:
标准中注册的声明(建议但不强制使用)
公共的声明
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密。
私有的声明
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
这个指的就是自定义的claim。比如下面那个举例中的name都属于自定的claim。这些claim跟JWT标准规定的claim区别在于:JWT规定的claim,JWT的接收方在拿到JWT之后,都知道怎么对这些标准的claim进行验证(还不知道是否能够验证);而private claims不会验证,除非明确告诉接收方要对这些claim进行验证以及规则才行。
其中sub是标准的声明, name是自定义的声明(公共的或私有的)。然后将其进行base64编码,得到Jwt的第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbWVzIiwiYWRtaW4iOnRydWV9
注意:声明中不要放一些敏感信息,比如密码。
(3)签证、签名(signature)
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:header (base64后的)、payload (base64后的)、secret(盐,一定要保密)。
这个部分需要base64加密后的header和base64加密后的payload使用.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分:
8HI-Lod0ncfVDnbKIPJJqLH998duF9DSDGkx3gRPNVI
将这三部分用.
连接成一个完整的字符串,构成了最终的jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.8HI-Lod0ncfVDnbKIPJJqLH998duF9DSDGkx3gRPNVI
JJWT是一个提供端到端的JWT创建和验证的Java库。永远免费和开源(Apache License,版本2.0),JJWT很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。
以下通过一个demo来快速入门。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.8.RELEASE</version>
<relativePath />
</parent>
<groupId>com.ycz</groupId>
<artifactId>jjwt-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>jjwt-demo</name>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- web模块 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- jjwt依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<!-- 测试包依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project
(3)token的创建
直接在测试类里写了。如下:
@SpringBootTest
class JjwtDemoApplicationTests {
/*
* 测试Token的生成
*/
@Test
public void createToken() {
//创建JwtBuilder对象
JwtBuilder jwtBuilder = Jwts.builder()
.setId("0918")//声明的标识
.setSubject("yanchengzhi")//主体
.setIssuedAt(new Date())//创建日期
//签名,第一个参数是算法,第二个参数是盐
.signWith(SignatureAlgorithm.HS256, "likeyou");
//获取jwt的Token
String token = jwtBuilder.compact();
System.out.println(token);
System.out.println("--------------------------------------");
//分割
String []strs = token.split("\\.");
String header = Base64Codec.BASE64.decodeToString(strs[0]);
String payload = Base64Codec.BASE64.decodeToString(strs[1]);
//第三部分解析出来一定会乱码
String sign = Base64Codec.BASE64.decodeToString(strs[2]);
System.out.println("头部:" + header);
System.out.println("负载:" + payload);
System.out.println("签名:" + sign);
}
}
执行这个方法,控制台:
将控制台的内容进行解析:
说明:sign部分解析出来是一定会乱码的,这正好说明了jwt的安全性非常好。
再次执行这个方法:
因为里面包含了时间,所以每次生成的jwt的token令牌都不一样。
(4)token的验证解析
已经创建了token ,在web应用中这个操作是由服务端进行然后发给客户端,客户端在下次向服务端发送请求时需要携带这个token(这就好像是拿着一张门票一样),那服务端接到这个token应该解析出token中的信息(例如用户id),根据这些信息查询数据库返回相应的结果。
以下用代码测试token的解析,在测试类中添加:
/*
* 测试Token的解析
*/
@Test
public void parseToken() {
String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIwOTE4Iiwic3ViIjoieWFuY2hlbmd6aGkiLCJpYXQiOjE2MTE1OTYzMjh9.shnFkp-39vyTxOfmTyexSDspCHP86RWTnsx-qsUkcK8";
//解析token,获取负载中的声明对象
Claims claims = Jwts.parser()
.setSigningKey("likeyou")//这个就是秘钥
.parseClaimsJws(token)
.getBody();
//获取信息
String id = claims.getId();
String sub = claims.getSubject();
Date date = claims.getIssuedAt();
System.out.println("ID:" + id);
System.out.println("签发人:" + sub);
System.out.println("签发时间:" + date);
}
有很多时候,我们并不希望签发的token是永久生效的(上节的token是永久的),所以我们可以为token添加一个过期时间。原因:从服务器发出的token,服务器自己并不做记录,就存在一个弊端就是,服务端无法主动控制某token的立刻失效。
直接修改createToken方法,修改后的代码如下:
/*
* 测试Token的生成
*/
@Test
public void createToken() {
//获取系统当前时间
long current = System.currentTimeMillis();
long expire = current + 1000 * 60;
//创建JwtBuilder对象
JwtBuilder jwtBuilder = Jwts.builder()
.setId("0918")//声明的标识
.setSubject("yanchengzhi")//主体
.setIssuedAt(new Date())//创建日期
//签名,第一个参数是算法,第二个参数是盐
.signWith(SignatureAlgorithm.HS256, "likeyou")
.setExpiration(new Date(expire));//设置过期时间,1分钟后失效
//获取jwt的Token
String token = jwtBuilder.compact();
System.out.println(token);
System.out.println("--------------------------------------");
//分割
String []strs = token.split("\\.");
String header = Base64Codec.BASE64.decodeToString(strs[0]);
String payload = Base64Codec.BASE64.decodeToString(strs[1]);
//第三部分解析出来一定会乱码
String sign = Base64Codec.BASE64.decodeToString(strs[2]);
System.out.println("头部:" + header);
System.out.println("负载:" + payload);
System.out.println("签名:" + sign);
}
执行,控制台:
然后将控制台生成的token替换掉parseToken方法中的token,执行parseToken这个方法,控制台:
一分钟后再执行这个方法:
报错了,提示jwt令牌过期。
(6)自定义claims
如果想存储更多的信息(例如角色),我们可以定义自定义claims。
直接修改createToken,修改后的如下:
@Test
public void createToken() {
//获取系统当前时间
long current = System.currentTimeMillis();
long expire = current + 1000 * 60;
//创建JwtBuilder对象
JwtBuilder jwtBuilder = Jwts.builder()
.setId("0918")//声明的标识
.setSubject("yanchengzhi")//主体
.setIssuedAt(new Date())//创建日期
//签名,第一个参数是算法,第二个参数是盐
.signWith(SignatureAlgorithm.HS256, "likeyou")
.setExpiration(new Date(expire))//设置过期时间,1分钟后失效
.claim("name", "ycz")//自定义claims
.claim("age", 25)
.claim("like", "uuuu")
.claim("wantto", "重庆");
//获取jwt的Token
String token = jwtBuilder.compact();
System.out.println(token);
System.out.println("--------------------------------------");
//分割
String []strs = token.split("\\.");
String header = Base64Codec.BASE64.decodeToString(strs[0]);
String payload = Base64Codec.BASE64.decodeToString(strs[1]);
//第三部分解析出来一定会乱码
String sign = Base64Codec.BASE64.decodeToString(strs[2]);
System.out.println("头部:" + header);
System.out.println("负载:" + payload);
System.out.println("签名:" + sign);
}
@Test
public void createToken() {
//获取系统当前时间
long current = System.currentTimeMillis();
long expire = current + 1000 * 60;
Map<String,Object> map = new HashMap<>();
map.put("name", "ycz");
map.put("age", 25);
map.put("like", "uuuu");
map.put("wantto", "重庆");
//创建JwtBuilder对象
JwtBuilder jwtBuilder = Jwts.builder()
.setId("0918")//声明的标识
.setSubject("yanchengzhi")//主体
.setIssuedAt(new Date())//创建日期
//签名,第一个参数是算法,第二个参数是盐
.signWith(SignatureAlgorithm.HS256, "likeyou")
.setExpiration(new Date(expire))//设置过期时间,1分钟后失效
.addClaims(map);
//获取jwt的Token
String token = jwtBuilder.compact();
System.out.println(token);
System.out.println("--------------------------------------");
//分割
String []strs = token.split("\\.");
String header = Base64Codec.BASE64.decodeToString(strs[0]);
String payload = Base64Codec.BASE64.decodeToString(strs[1]);
//第三部分解析出来一定会乱码
String sign = Base64Codec.BASE64.decodeToString(strs[2]);
System.out.println("头部:" + header);
System.out.println("负载:" + payload);
System.out.println("签名:" + sign);
}
推荐使用这种,将全部信息放到map集合,然后入参这个map就行了。执行,控制台:
然后解析的时候将这些内容获取出来,修改parseToken,如下:
/*
* 测试Token的解析
*/
@Test
public void parseToken() {
String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIwOTE4Iiwic3ViIjoieWFuY2hlbmd6aGkiLCJpYXQiOjE2MTE1OTkwMjgsImV4cCI6MTYxMTU5OTA4OCwid2FudHRvIjoi6YeN5bqGIiwibGlrZSI6InV1dXUiLCJuYW1lIjoieWN6IiwiYWdlIjoyNX0.cjyqcgJowoYYwyVhJdloqraYJ4FgVqMXfhZzPp9FyVA";
//解析token,获取负载中的声明对象
Claims claims = Jwts.parser()
.setSigningKey("likeyou")//这个就是秘钥
.parseClaimsJws(token)
.getBody();
//获取信息
String id = claims.getId();
String sub = claims.getSubject();
Date date = claims.getIssuedAt();
String name = (String) claims.get("name");
int age = (Integer) claims.get("age");
String like = (String) claims.get("like");
String want = (String) claims.get("wantto");
System.out.println("ID:" + id);
System.out.println("签发人:" + sub);
System.out.println("签发时间:" + date);
System.out.println("姓名:" + name);
System.out.println("年龄:" + age);
System.out.println("喜欢的人:" + like);
System.out.println("想要去的地方:" + want);
}
前面只使用Oauth2.0的话,颁发的通行令牌长度太短了,现在想整合JWT,将颁发的token转换一下,转换成jwt格式的长令牌。
在config包下创建JwtTokenStoreConfig配置类,如下:
/*
* Jwt配置类
*/
@Configuration
public class JwtTokenStoreConfig {
//注册JwtAccessTokenConverter
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
//配置jwt秘钥
jwtAccessTokenConverter.setSigningKey("yanchengzhi");
return jwtAccessTokenConverter();
}
//注册TokenStore
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
}
(4)修改授权配置类
修改AuthorizationServerConfig类,如下:
标记的地方是添加或修改的。
(5)测试
使用密码模式获取jwt令牌,如下:
返回结果如下:
现在的access_token令牌的长度发生了变化,与它对应的是jti值。解析这个token值:
没问题,解析的结果也是正确的。
现在想往JWT令牌中添加自定义的内容,过程如下。
(1)Jwt内容增强器
创建一个jwt包,包下创建一个Jwt的内容增强器JwtTokenEnhancer,如下:
/*
* Jwt内容增强器
* 需要实现TokenEnhancer接口
*
*/
public class JwtTokenEnhancer implements TokenEnhancer{
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken,
OAuth2Authentication authentication) {
//自定义的内容存到map中
Map<String,Object> map = new HashMap<>();
map.put("address","湖北");
map.put("like","uuuu");
map.put("age",25);
map.put("qq昵称","云过梦无痕");
//下转型
if(accessToken instanceof DefaultOAuth2AccessToken) {
DefaultOAuth2AccessToken defaultOAuth2AccessToken = (DefaultOAuth2AccessToken)accessToken;
defaultOAuth2AccessToken.setAdditionalInformation(map);
return defaultOAuth2AccessToken;
}
return null;
}
}
(2)修改Jwt配置类
修改Jwt配置类JwtTokenStoreConfig,如下:
标记的是新添加的内容。
(3)修改授权服务配置
修改AuthorizationServerConfig类,如下:
标记的是新添加的内容。
(4)测试
启动工程,使用postman进行测试:
发送请求,返回结果如下:
access_token令牌长度变长了,而且下面是我添加的自定义的内容。解析生成的jwt令牌:
令牌中包含添加的自定义内容,但是后面的乱码了。
JWT令牌的内容一般要在java程序中解析出来,以下演示过程。
(1)pom依赖
还是使用jjwt来解析。pom中添加依赖:
<!-- jjwt依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
(2)controller
修改UserController,如下:
@RestController
@RequestMapping("/user")
public class UserController {
@RequestMapping("/getCurrentUser")
public Object getCurrentUser(Authentication authentication,HttpServletRequest request) {
//获取请求头的指定内容
String header = request.getHeader("Authorization");
//截取,去掉请求头的前6位,获取token
String token = header.substring(header.indexOf("bearer") + 7);
//解析Token,获取Claims对象
Claims claims = Jwts.parser()
.setSigningKey("yanchengzhi".getBytes(StandardCharsets.UTF_8))
.parseClaimsJws(token)
.getBody();
return claims;
}
}
(3)测试
先获取jwt令牌:
然后带着jwt令牌去获取资源:
请求头Header里面带一个参数Authorization,值为bearer后面加一个空格,然后跟jwt令牌内容,发送,返回结果:
获取到了,返回的是jwt令牌解析后的内容。
在Spring Cloud Security中使用oauth2时,如果令牌失效了,可以使用刷新令牌通过refresh_token的授权模式再次获取access_token,只需修改认证服务器的配置,添加refresh_token的授权模式即可。
修改授权服务配置类AuthorizationServerConfig,如下:
给jwt通行令牌加一个有效期,这里有效期设为1分钟。启动工程进行测试:
现在jwt通行令牌还有效,1分钟后再发请求:
获取不到资源了,现在jwt通行令牌已经过期了。解决方法是加一个刷新令牌refresh_token。如下:
授权模式里面添加一个refresh_token并且设置刷新令牌的有效期。然后再启动工程获取令牌:
现在返回结果里面多了一个refresh_token,这个就是刷新令牌。等1分钟,用通行令牌获取资源:
通行令牌过期了。现在使用刷新令牌直接从授权服务端获取新的通行令牌:
表单里面带2个参数:grant_type的值为refresh_token,refresh_token的值为刚才返回的刷新令牌。发送:
获取到了新的通行令牌和刷新令牌。同样的,通行令牌的有效期还是1分钟,刷新令牌是1小时,再用这个新的通行令牌获取资源:
资源获取成功。
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
如下图:
如上图,假设有4个子系统。现在用户首次登录子系统1:
通过以上过程可以看到,用户只需要成功登录一次,就可以不用再进行登录而直接进入其他子系统。
(1)创建工程
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.8.RELEASE</version>
<relativePath />
</parent>
<groupId>com.ycz</groupId>
<artifactId>system1</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>system1</name>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR2</spring-cloud.version>
</properties>
<dependencies>
<!-- spring cloud中的oauth2依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<!-- spring cloud中的security依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<!-- web模块 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- jjwt依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<!-- 测试包 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<!-- 引入spring cloud -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${
spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
(3)yml配置
application.yml的内容如下:
server:
port: 9900
##防止Cookie冲突
servlet:
session:
cookie:
name: session01
## 授权服务地址
oauth2-server-url: http://localhost:9999
security:
oauth2:
## 客户端配置
client:
client-id: client ## 客户端ID
client-secret: ycz123456 ## 密码
user-authorization-uri: ${
oauth2-server-url}/oauth/authorize ## 获取授权码的url
access-token-uri: ${
oauth2-server-url}/oauth/token ## 获取access_token的url
## 服务端配置
resource:
jwt:
## 获取jwt令牌的url
key-uri: ${
oauth2-server-url}/oauth/token_key
(4)开启sso功能
启动类上加@EnableOAuth2Sso注解,如下:
@SpringBootApplication
//开启sso单点登录功能
@EnableOAuth2Sso
public class System1Application {
public static void main(String[] args) {
SpringApplication.run(System1Application.class, args);
}
}
(5)controller
controller包下创建UserController,如下:
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/getCurrentUser")
public Object getCurrentUser(Authentication authentication) {
return authentication.getPrincipal();
}
}
(6)修改认证服务器的配置
修改AuthorizationServerConfig类,如下:
修改重定向的uri,下面添加的。
同时启动system1工程和springsecurity-oauth2-demo工程。浏览器访问如下:
需要认证服务的认证输入用户名和密码,登录:
成功获取资源,再看调试工具:
成功写到了Cookie中。
(7)添加一个子系统
复制system1系统,重命名system2如下:
只修改yml:
如上,改了端口和Cookie的名称。
重新启动这3个工程,访问如下:
跳到了登录页面:
输入账号密码登录:
成功获取到了资源。
再通过第二个子系统访问资源:
成功访问到了资源。可以看到子系统2并没有再进行登录,而是直接可以访问资源,这就是单点登录SSO,只用成功登录一次便可以在所有子系统中通行。再看Cookie:
成功的添加了一个名称为session02的Cookie。
通过学习,掌握Spring Security、Oauth2.0、JWT的用法。以及整合Spring Security + Oauth2.0 + JWT进行SSO单点登录。