SpringSecurity
安全框架
安全框架就是解决系统安全的框架。如果没有安全框架,我们需要手动的处理每个资源的访问控制,这是非常麻烦的。使用了安全框架,我们可以通过配置的方式实现对资源的访问限制。
常用安全框架
- Apache Shiro 一个功能强大且易于使用的Java安全框架,提供了认证、授权、加密、会话管理。
- Spring Security Spring家族的一员,是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架。它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring的IOC(控制反转)、DI(依赖注入)和AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,为了减少企业系统安全控制编写大量重复代码的工作。
关于Spring Security
Spring Security是一个高度自定义的安全框架。利用Spring IOC、DI和AOP的功能,为系统提供了声明式安全访问控制功能,减少了为系统安全而编写大量重复代码的工作。使用Spring Security的原因很多,但大部分都是发现了Java EE的Servlet规范或EJB规范中的安全功能缺乏典型的企业级应用场景,同时认识到他们在WAR或EAR级别无法移植。因此如果更换服务器环境,还有大量工作去重写配置应用程序。使用Spring Security解决了这些问题,也为你提供许多其他有用的、可定制的安全功能。应用程序的两个主要区域是认证和授权(访问控制)。这两点也是Spring Security重要的核心功能。认证是建立一个他声明的主体的过程,一个主体一般指用户,设备或者一些可以在你的应用程序中执行动作的其他系统,简单来说就是系统认为用户是否能登录。授权指确定一个主体是否允许在你的应用程序中执行一个动作的过程,简单来说就是系统判断用户是否有权限去执行某些操作。
快速入门
<?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-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>
前端页面
登录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>
controller层
@Controller public class LoginController { /* * 登录 */ @RequestMapping("/login") public String login() { return "redirect:main.html"; } }
启动项目,访问localhost:8888/login.html
进入了SpringSecurity的内置页面,需要验证,来到控制台:
验证成功后来到了登录页面。
UserDetailsService
public interface UserDetailsService { /** * Locates the user based on the username. In the actual implementation, the search * may possibly be case sensitive, or case insensitive depending on how the * implementation instance is configured. In this case, the <code>UserDetails</code> * object that comes back may have a username that is of a different case than what * was actually requested.. * @param username the username identifying the user whose data is required. * @return a fully populated user record (never <code>null</code>) * @throws UsernameNotFoundException if the user could not be found or the user has no * GrantedAuthority */ UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }
用户的登录是访问这个接口的唯一方法loadUserByUsername的,这个方法接收一个参数,就是用户名,如果没有,会抛异常。如果有,返回UserDetails。
UserDetails也是Spring Security提供的一个接口,源码如下
public interface UserDetails extends Serializable { /** * Returns the authorities granted to the user. Cannot return <code>null</code>. * @return the authorities, sorted by natural key (never <code>null</code>) */ Collection<? extends GrantedAuthority> getAuthorities(); /** * Returns the password used to authenticate the user. * @return the password */ String getPassword(); /** * Returns the username used to authenticate the user. Cannot return * <code>null</code>. * @return the username (never <code>null</code>) */ String getUsername(); /** * Indicates whether the user's account has expired. An expired account cannot be * authenticated. * @return <code>true</code> if the user's account is valid (ie non-expired), * <code>false</code> if no longer valid (ie expired) */ boolean isAccountNonExpired(); /** * Indicates whether the user is locked or unlocked. A locked user cannot be * authenticated. * @return <code>true</code> if the user is not locked, <code>false</code> otherwise */ boolean isAccountNonLocked(); /** * Indicates whether the user's credentials (password) has expired. Expired * credentials prevent authentication. * @return <code>true</code> if the user's credentials are valid (ie non-expired), * <code>false</code> if no longer valid (ie expired) */ boolean isCredentialsNonExpired(); /** * Indicates whether the user is enabled or disabled. A disabled user cannot be * authenticated. * @return <code>true</code> if the user is enabled, <code>false</code> otherwise */ boolean isEnabled(); } 这个接口有7个抽象方法,值得注意的前3个方法,第1个方法是获取权限的,第2个方法获取密码,第3个方法获取用户名。
UserDetails接口有一个实现类User,部分源码如下
public class User implements UserDetails, CredentialsContainer { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; private static final Log logger = LogFactory.getLog(User.class); private String password; private final String username; private final Set<GrantedAuthority> authorities; private final boolean accountNonExpired; private final boolean accountNonLocked; private final boolean credentialsNonExpired; private final boolean enabled;
有一个构造方法:
这个构造方法有3个参数:用户名、密码、权限。然后里面调用了重载的另一个构造方法:
public User(String username, String password, Collection<? extends GrantedAuthority> authorities) { this(username, password, true, true, true, true, authorities); } public User(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) { Assert.isTrue(username != null && !"".equals(username) && password != null, "Cannot pass null or empty values to constructor"); this.username = username; this.password = password; this.enabled = enabled; this.accountNonExpired = accountNonExpired; this.credentialsNonExpired = credentialsNonExpired; this.accountNonLocked = accountNonLocked; this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities)); }
这个重载的构造方法有7个参数,除了用户名、密码、权限外,还有账号是否启用、是否过期、是否锁定等。
PasswordEncoder密码解析器
PasswordEncoder是Spring Security提供的一个接口,称它为密码解析器,这个接口主要是处理密码的。源码如下:
public interface PasswordEncoder { /** * Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or * greater hash combined with an 8-byte or greater randomly generated salt. */ String encode(CharSequence rawPassword); /** * Verify the encoded password obtained from storage matches the submitted raw * password after it too is encoded. Returns true if the passwords match, false if * they do not. The stored password itself is never decoded. * @param rawPassword the raw password to encode and match * @param encodedPassword the encoded password from storage to compare with * @return true if the raw password, after encoding, matches the encoded password from * storage */ boolean matches(CharSequence rawPassword, String encodedPassword); /** * Returns true if the encoded password should be encoded again for better security, * else false. The default implementation always returns false. * @param encodedPassword the encoded password to check * @return true if the encoded password should be encoded again for better security, * else false. */ default boolean upgradeEncoding(String encodedPassword) { return false; } }
接口提供3个方法,第一个方法是对明文密码进行加密的,返回一个密文。第二个方法是匹配明文密码和密文,返回布尔值。第三个方法是对密文进行二次加密,这个方法是默认的。
PasswordEncoder接口有很多实现类,其中最主要的是官方推荐的BCryptPasswordEncoder类,平时使用的最多的就是这个密码解析器。BCryptPasswordEncoder是对bcrypt强散列方法的具体实现,是基于hash算法的单向加密。可以通过strength来控制强度,默认是10。
public class BCryptPasswordEncoder implements PasswordEncoder { private Pattern BCRYPT_PATTERN = Pattern.compile("\\A\\$2(a|y|b)?\\$(\\d\\d)\\$[./0-9A-Za-z]{53}"); private final Log logger = LogFactory.getLog(getClass()); private final int strength; private final BCryptVersion version; private final SecureRandom random; public BCryptPasswordEncoder() { this(-1); } /** * @param strength the log rounds to use, between 4 and 31 */ public BCryptPasswordEncoder(int strength) { this(strength, null); } /** * @param version the version of bcrypt, can be 2a,2b,2y */ public BCryptPasswordEncoder(BCryptVersion version) { this(version, null); } /** * @param version the version of bcrypt, can be 2a,2b,2y * @param random the secure random instance to use */ public BCryptPasswordEncoder(BCryptVersion version, SecureRandom random) { this(version, -1, random); } /** * @param strength the log rounds to use, between 4 and 31 * @param random the secure random instance to use */ public BCryptPasswordEncoder(int strength, SecureRandom random) { this(BCryptVersion.$2A, strength, random); } /** * @param version the version of bcrypt, can be 2a,2b,2y * @param strength the log rounds to use, between 4 and 31 */ public BCryptPasswordEncoder(BCryptVersion version, int strength) { this(version, strength, null); } /** * @param version the version of bcrypt, can be 2a,2b,2y * @param strength the log rounds to use, between 4 and 31 * @param random the secure random instance to use */ public BCryptPasswordEncoder(BCryptVersion version, int strength, SecureRandom random) { if (strength != -1 && (strength < BCrypt.MIN_LOG_ROUNDS || strength > BCrypt.MAX_LOG_ROUNDS)) { throw new IllegalArgumentException("Bad strength"); } this.version = version; this.strength = (strength == -1) ? 10 : strength; this.random = random; } @Override public String encode(CharSequence rawPassword) { if (rawPassword == null) { throw new IllegalArgumentException("rawPassword cannot be null"); } String salt = getSalt(); return BCrypt.hashpw(rawPassword.toString(), salt); } private String getSalt() { if (this.random != null) { return BCrypt.gensalt(this.version.getVersion(), this.strength, this.random); } return BCrypt.gensalt(this.version.getVersion(), this.strength); }
encode方法是对明文密码进行加密,原理是使用一个随机生成的salt,用明文密码加上这个salt来一起进行加密,返回密文,由于这个salt每次生成的都不一样,所以即使明文密码一样,最后加密出来的密文是不一样的,这样保证了用户密码的安全。
@Override public boolean matches(CharSequence rawPassword, String encodedPassword) { if (rawPassword == null) { throw new IllegalArgumentException("rawPassword cannot be null"); } if (encodedPassword == null || encodedPassword.length() == 0) { this.logger.warn("Empty encoded password"); return false; } if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) { this.logger.warn("Encoded password does not look like BCrypt"); return false; } return BCrypt.checkpw(rawPassword.toString(), encodedPassword); }
matchs方法是用来匹配明文密码和密文的,最终结果用布尔值返回。
测试加密和匹配:
@Test void contextLoads() { PasswordEncoder ps=new BCryptPasswordEncoder(); //加密 String encode = ps.encode("123"); System.out.println(encode); //比较密码 boolean matches = ps.matches("123", encode); System.out.println("============"); System.out.println(matches); }
自定义登录逻辑
当我们自定义登录逻辑时,需要用到UserDetailsService和PasswordEncoder,Spring Security要求自定义登录逻辑时容器内必须要有PasswordEncoder实例,不能new出来,因此需要一个配置类来向容器中注入。
创建config包,包下定义SecurityConfig配置类:
@Configuration public class SecurityConfig { @Bean public PasswordEncoder getPw(){ return new BCryptPasswordEncoder(); } }
需要有一个类来实现UserDetailsService接口,如下:
@Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //实际是根据用户名去数据库查,这里就直接用静态数据了 //不存在抛UsernameNotFoundException异常 if(!"admin".equals(username)){ throw new UsernameNotFoundException("用户名不存在"); } //比较密码,匹配成功会返回UserDetails,实际上也会去数据库查 String encode = passwordEncoder.encode("123"); return new User(username,encode, AuthorityUtils.commaSeparatedStringToAuthorityList("admin")); } }
自定义登录页面
Spring Security提供了登录页面,就是需要验证的那个页面。但是一般在实际项目中会用自己定义好的登录页面,如果想用自己定义好的登录页面,比如这里的login.html,只需要修改配置类即可。
修改后的配置类如下:
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder getPw(){ return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { //表单提交 http.formLogin() .loginPage("/login.html") //必须和from表单提交的接口路径一致,会去执行自定义登录逻辑 .loginProcessingUrl("/login") //登录成功后跳转到的页面,只接受post请求 .successForwardUrl("/toMain"); //授权 http.authorizeRequests() .antMatchers("/login.html").permitAll()//放行的路径 //所有的请求都必须认证才能访问 .anyRequest().authenticated(); //关闭csrf防护 http.csrf().disable(); } }
LoginController修改如下:
@Controller public class LoginController { /* * 登录 */ @RequestMapping("/toMain") public String login() { return "redirect:main.html"; } }
自定义错误页面
和自定义登录页面一样,修改SecurityConfig配置类:
http.formLogin() .loginPage("/login.html") //必须和表单提交的接口路径一致,会去执行自定义登录逻辑 .loginProcessingUrl("/login") //登录成功后跳转到的页面,只接受post请求 .successForwardUrl("/toMain") .failureForwardUrl("/toError"); //授权 http.authorizeRequests() .antMatchers("/login.html").permitAll()//放行的路径 .antMatchers("/error.html").permitAll()//放行的路径 //所有的请求都必须认证才能访问 .anyRequest().authenticated();
然后LoginController控制器添加:
/* * 错误跳转 */ @RequestMapping("/toError") public String error() { return "redirect:error.html"; }
自定义用户名和密码参数名
标记的地方是必须这样写的,也就是固定,提交的地址必须是login,提交方法必须是post,用户名的参数必须是username,密码的参数必须是password。原因是Spring Security定义了一个UsernamePasswordAuthenticationFilter拦截器,拦截器里面已经定死了。
UsernamePasswordAuthenticationFilter拦截器源码如下:
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST"); private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY; private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY; private boolean postOnly = true; @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } String username = obtainUsername(request); username = (username != null) ? username : ""; username = username.trim(); String password = obtainPassword(request); password = (password != null) ? password : ""; UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); // Allow subclasses to set the "details" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } @Nullable protected String obtainPassword(HttpServletRequest request) { return request.getParameter(this.passwordParameter);//静态的 }
获取用户名参数和密码参数时按照定义的username和password参数来获取,如果表单里面的和这里不一样,是获取不到的。
但是它提供了方法可以修改用户名和密码的参数名:
public void setUsernameParameter(String usernameParameter) { Assert.hasText(usernameParameter, "Username parameter must not be empty or null"); this.usernameParameter = usernameParameter; } public void setPasswordParameter(String passwordParameter) { Assert.hasText(passwordParameter, "Password parameter must not be empty or null"); this.passwordParameter = passwordParameter; }
通过这两个方法可以修改参数名称。
修改配置类,如下:
http.formLogin() .usernameParameter("username123") .passwordParameter("password123") .loginPage("/login.html") //必须和表单提交的接口路径一致,会去执行自定义登录逻辑 .loginProcessingUrl("/login") //登录成功后跳转到的页面,只接受post请求 .successForwardUrl("/toMain") .failureForwardUrl("/toError");
自定义成功登录处理器
发现是通过标记的这个方法来实现成功跳转的,那么我们可以自己实现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); } }
然后配置类里修改如下:
自定义失败登录处理器
本质上是调用了ForwardAuthenticationFailureHandler处理器的onAuthenticationFailure方法来实现跳转。我们也可以自己实现AuthenticationFailureHandler接口,自定义失败跳转逻辑。
在handler包下创建MyAuthenticationFailureHandler,内容如下
public class MyAuthenticationFailurHandler implements AuthenticationFailureHandler { private String url; public MyAuthenticationFailurHandler(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图片:
这个被拦截了。访问t.png图片:
可以访问,说明放行的路径起作用了。
(3)regexMatchers
可以使用正则表达式来匹配放行路径,如下:
访问:
还可以规定请求方式:
在控制器里添加:
放行路径
这里只放行post请求的test路径,访问:
被拦截了,因为/test是get请求的,修改放行路径:
这里改为GET请求,再访问:
请求成功,说明放行了。
(4)mvcMatchers
一般和servletPath一起用,相当于加应用前缀。在application.yml中添加:
修改配置类:
启动项目,访问:
加前缀再访问:
这种方式其实完全可以用antMatchers替代,如下:
再访问:
可以看到有6种属性,每种属性都对应一个方法,permitAll是允许所有,denyAll是拒绝所有,anonymous是允许匿名的,authenticated是需要认证,fullyAuthenticated是需要完整的认证,rememberMe是记住,比如7天免登录这种。这6个方法无法单独使用,需要配合antMatchers等方法一起使用。
2.13、角色权限判断
除了内置权限控制。Spring Security中还支持很多其他权限控制。这些方法一般都用于用户已经被认证后,判断用户是否具有特定的要求。
这里直接指定了两个静态的数据作为权限。
主页面中加一个跳转:
main1页面如下:
修改yml配置文件:
修改Spring Security配置文件:
拥有ycz这个权限才能访问main1.html这个页面。
(2)基于角色判断
如果用户具备给定角色就允许访问。否则出现 403。参数取值来源于自定义登录逻辑 UserDetailsService实现类中创建 User 对象时给User赋予的授权。
必须以ROLE_开头,这是固定格式,后面接角色名称。修改配置文件:
启动工程,主页面中点击链接:
可以访问,如果用户没有该角色,就无法访问,类似权限的控制。
(3)基于IP地址控制
只允许指定的IP地址访问,其他的IP拒绝访问,如下:
启动,点击主页面中的链接:
访问成功。
自定义403处理方案
使用Spring Security时经常会看见403(无权限)
报403页面是因为权限不足。源码:
可以自定义类实现这个接口,并且修改规则。
在handler包中新建MyAccessDeniedHandler类,如下:
@Component public class MyAcessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { //响应状态 response.setStatus(HttpServletResponse.SC_FORBIDDEN); //返回j'son格式 response.setHeader("Content-Type","application/json;charset=utf-8"); PrintWriter writer = response.getWriter(); writer.write("{\"status\":\"error\",\"msg\":\"权限不足,请联系管理员\"}"); writer.flush(); writer.close(); } }
修改配置类:
标记的是新添加的。启动工程,点击主页面中的链接:
是自定义的json串,说明设置起了作用。
基于表达式的访问控制
1、access()方法
先看一下之前的基于角色、权限、IP地址以及内置的访问控制的底层:
可以看到,权限判断实际上底层实现都是调用access(表达式)。以下为常见的内置表达式:
之前的权限控制其实使用access表达式也可以达到相同的控制效果,比如下面:
可以修改成下面这样:
启动工程测试:
可以访问。
自定义方法
在实际项目中很有可能出现需要自己自定义逻辑的情况。比如判断登录用户是否具有访问当前URL权限。
新建接口:
public interface MyService { //判断是否有权限 boolean hasPermission(HttpServletRequest request,Authentication authentication); } 123456
接口的实现类:
@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; } } 1234567891011121314151617181920212223
修改配置类:
启动项目,登录:
没有权限,原因是登录成功后的url应该是
/main.html
,但是在这里并没有:需要添加进去:
基于注解的访问控制
在Spring Security中提供了一些访问控制的注解。这些注解都是默认是都不可用的,需要通过@EnableGlobalMethodSecurity进行开启后使用。如果设置的条件允许,程序正常执行,如果不允许会报500错误。
这些注解可以写到Service接口或方法上,也可以写Controller或Controller的方法上。通常情况下都是写在控制器方法上的,控制接口URL是否允许被访问。
@Secured注解
@Secured是专门用于判断是否具有角色的,能写在方法或类上,参数要以 ROLE_开头。源码如下:
(1)开启注解
在启动类或者配置类等能够扫描的类上添加,这里直接在配置类上添加:
说明一下,这个注解里面的所有值都是默认为false的,要用的话必须手动改为true。源码如下:
(2)在Controller的方法上添加注解
在需要的方法上添加@Secured注解:
(3)配置类配置类需要修改一下,将之前通过配置判断权限都取消,要不然可能出问题:
启动项目,测试登录
区分大小写
因为角色不对,方法是受保护的,不允许访问。可以看出,只有角色正确才能够访问对应的方法。@Secured注解和hasRole这个方法的作用是一样的,通过角色判断。
@PreAuthorize和@PostAuthorize注解
@PreAuthorize和@PostAuthorize都是方法或类级别注解。源码如下:
这两个注解的区别如下:
- @PreAuthorize:表示访问方法或类在执行之前先判断权限,大多情况下都是使用这个注解,注解的参数和access()方法参数取值相同,都是权限表达式。
- @PostAuthorize:表示方法或类执行结束后判断权限,此注解很少被使用到。
实际开发中基本上用的都是@PreAuthorize注解,在执行之前进行判断,判断是否具有操作方法的权限,如果是之后再判断,那就没什么意义了,所以@PostAuthorize注解用的不多
(1)开启注解
和上面的类似,这里也是在配置类上开启:
(2)在Controller的方法上添加注解在控制器方法上添加@PreAuthorize,参数可以是任何 access()支持的表达式。
这里是对角色判断,并且加了前缀ROLE的,先测试错误的角色:
再测试正确的角色:
可以访问。然后测试不加前缀:
还是以访问,说明一个问题:如果是在@PreAuthorize注解中的话,可以加ROLE前缀,也以不加,但是在配置中一定不能加前缀。
再测试通过权限访问:
可以访问,那么发现@PreAuthorize注解对角色和权限控制都行,事实上,只要是access()方法里面的,这个注解都支持。
RememberMe实现
Spring Security中Remember Me为“记住我”功能,用户只需要在登录时添加remember-me复选框,取值为true。Spring Security会自动把用户信息存储到数据源中,以后就可以不登录进行访问。
(1)前端
在login.html中添记住我复选框:
(2)pom依赖
会用到数据库,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> 12345678910111213
(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 123456
需要在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; } } 12345678910111213141516171819202122
(5)修改SecurityConfig配置
在SecurityConfig配置类中添加:
@Autowired private UserDetailsServiceImpl userDetailsServiceImpl; @Autowired private PersistentTokenRepository persistentTokenRepository; //记住我功能 http.rememberMe() //参数名,和表单中的一样 .rememberMeParameter("rememberMe") //持久层对象 .tokenRepository(persistentTokenRepository) //登录逻辑设置 .userDetailsService(userDetailsServiceImpl) //失效时间,默认为两周,这里设为60秒 .tokenValiditySeconds(60); 12345678910111213141516
(6)测试
启动工程,查看数据库:
自动建了一张表,表中无数据。
访问登录页面进行登录,勾选复选框:
登录成功,查看数据库中的表:
表中自动添加了1条数据。然后关闭浏览器,再打开,直接访问
不用登录,可以访问。等1分钟后,再关闭浏览器,再打开,直接访问主页面:
失效了,再次登录,勾选复选框:
登录成功,再查看数据表:
新增了一条数据,也就是说,用户每次登录,只要勾选了记住我复选框,登录成功后都会往表中写入一条数据,表的时间字段是用来和用户再次登录的时间戳进行对比的,如果时间差小于1分钟,就是免登录,如果时间差大于1分钟,记住我功能就会失效,需要用户重新进行登录。
Thymeleaf中使用SpringSecurity
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的内容。
根据源码得出下面属性:
- name :登录账号名称。
- principal :登录主体,在自定义登录逻辑中是UserDetails。
- credentials :凭证。
- authorities :权限和角色。
- details :实际上是WebAuthenticationDetails的实例。可以获取remoteAddress(客户端ip)和sessionId(当前 sessionId)。
(3)controller
在LoginController中添加如下方法:
@RequestMapping("/show") public String show() { return "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> 12345678910111213141516171819202122232425262728293031
启动工程,登录,再访问
。
按照权限判断的话用户只拥有添加和删除权限,上面确实只显示了这两个按钮。
按照角色判断的话,应该四个按钮都显示,结果也是这样。
退出登录
用户只需要向Spring Security项目中发送/logout 退出请求即可。
在main.html中添加退出链接:
修改配置类:
启动,登录到主页面:
点击退出登录的链接:
成功退出,来到了登录页面。源码分析:
按照源码的内容我们可以得出结论:退出的时候,session失效,并且将认证置为空,清空了上下文。
CSRF
前面其实已经配置过了:
如果没有这行代码会导致用户无法被认证。这行代码的含义是:关闭csrf防护。
CSRF概念
CSRF(Cross-site request forgery)跨站请求伪造,也被称为“OneClick Attack” 或者 Session Riding。通过伪造用户请求访问受信任站点的非法请求访问。
跨域:只要网络协议,ip地址,端口中任何一个不相同就是跨域请求。
客户端与服务进行交互时,由于http协议本身是无状态协议,所以引入了cookie进行记录客户端身份。在cookie中会存放session id用来识别客户端身份的。在跨域的情况下,session id 可能被第三方恶意劫持,通过这个session id向服务端发起请求时,服务端会认为这个请求是合法的,可能发生很多意想不到的事情。
Spring Security中的CSRF
从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)配置类修改
修改SecurityConfig配置类如下:
标记的地方需要修改成showLogin。
然后csrf需要开启:
这行代码注释掉。然后启动工程测试:
以这个url访问登录页面,注意,这个登录页面是templates下的模板,不是原来的static目录下的login.html。输入用户名密码,登录:
因为开启了CSRF,所以是认证不成功的。修改登录模板文件:
添加一个隐藏的文本框即可。再启动工程测试:
登录成功。打开浏览器的调试工具:
可以看到表单提交的时候提交了csrf的值,说明在页面中获取到了。csrf在实际开发中非常重要,对于用户的账号安全起着至关重要的作用,可以防止用户的sessionId被第三方劫持,因此csrf都不会关闭,会一直保持开启状态。
OAuth2.0
OAuth简介
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简介
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步用户其实是看不到的,用户只用扫码并在微信同意授权,然后就登录成功,后面的这几步是由应用来完成的。
Auth2.0的认证流程
Oauth2.0认证流程如下:
过程说明:
- 客户端请求资源拥有者的授权。
- 资源拥有者同意授权,并且返回给客户端一个授权凭证。
- 客户端带着授权凭证去认证服务器进行认证。
- 认证通过后会颁发给客户端一个访问令牌。
- 客户端携带这个访问令牌去请求资源服务器上的资源。
- 验证访问令牌,令牌合法后资源服务器会将客户端请求的资源返给客户端。
认证流程中的各个角色:
客户端
本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:Android客户端、Web客户端(浏览器端)、微信客户端等。
资源拥有者
通常为用户,也可以是应用程序,即该资源的拥有者。
授权服务器(也称认证服务器)
用来对资源拥有的身份进行认证、对访问资源进行授权。客户端要想访问资源需要通过认证服务器由资源拥有者授权后方可访问。
资源服务器
存储资源的服务器,比如,网站用户管理服务器存储了网站用户信息,网站相册服务器存储了用户的相册信息,微信的资源服务存储了微信的用户信息等。客户端最终访问资源服务器获取资源信息。
一些专有名词:
- 客户凭证(client Credentials) :客户端的clientId和密码用于认证客户。
- 令牌(tokens) :授权服务器在接收到客户请求后,颁发的访问令牌。
- 作用域(scopes) :客户请求访问令牌时,由资源拥有者额外指定的细分权限(permission)。
令牌类型:
- 授权码 :仅用于授权码授权类型,用于交换获取访问令牌和刷新令牌。
- 访问令牌 :用于代表一个用户或服务直接去访问受保护的资源。
- 刷新令牌 :用于去授权服务器获取一个刷新访问令牌。
- BearerToken :不管谁拿到Token都可以访问资源,类似现金。
- Proof of Possession(PoP) Token :可以校验client是否对Token有明确的拥有权。
OAuth2.0认证的优缺点
优点:
- 更安全,客户端不接触用户密码,服务器端更易集中保护。
- 广泛传播并被持续采用。
- 短寿命和封装的token。
- 资源服务器和授权服务器解耦。
- 集中式授权,简化客户端。
- HTTP/JSON友好,易于请求和传递token。
- 考虑多种客户端架构场景。
- 客户可以具有不同的信任级别。
缺点:
- 协议框架太宽泛,造成各种实现的兼容性和互操作性差。
- 不是一个认证协议,本身并不能告诉你任何用户信息。
OAuth2.0的授权模式
OAuth2.0有4种授权模式:授权码模式、简化授权模式、密码模式、客户端模式。使用的最多的是授权码模式,这种在实际项目中用的最多,也最安全。
授权码模式(Authorization Code)
流程图如下:
过程说明:
- 用户访问页面。
- 访问页面将请求重定向到认证服务器。
- 认证服务器向用户展示授权页面,等待用户授权。
- 用户同意授权,认证服务器生成一个code和带上client_id发送给应用服务器。然后,应用服务器拿到code,并用client_id去后台查询对应的client_secret。
- 将code、client_id、client_secret传给认证服务器换取access_token和refresh_token。
- 认证服务器将access_token和refresh_token返给应用服务器。
- 应用服务器携带访问令牌access_token访问资源服务器的资源。
这种模式是最复杂也是最安全的,用的最多的,重点掌握这种模式。
简化授权模式(Implicit Grant)
流程图如下:
过程说明:
- 客户端访问应用页面,应用带着clientId将请求重定向到认证服务器。
- 认证服务器需要用户授权,用户同意授权。
- 认证服务器会向应用页面返回一个重定向的URI和存在Fragment中的Token,这个Token是无法获取到的。
- 应用页面根据返回的URI重定向到另一个客户端,这个客户端会返回一个脚本Script给应用。
- 应用页面会根据这个脚本来解析Token获取令牌,将令牌返回给客户端。
- 客户端根据令牌获取资源服务器的资源。
这种用的比较少。
密码模式(Resource Owner PasswordCredentials)
流程图如下:
过程说明:
- 资源拥有者在客户端输入用户名和密码。
- 客户端带着用户名和密码去认证服务器认证。
- 认证通过,认证服务器向客户端返回一个令牌Token。
- 客户端携带令牌去资源服务器获取资源。
这种用的也很少。
客户端模式(Client Credentials)
流程图如下:
说明:
- 客户端请求认证服务进行认证。
- 认证通过,认证服务向客户端返回一个令牌Token。
- 客户端携带令牌访问资源服务的资源。
比如Docker用的就是这种。
刷新令牌(Refresh Token)
刷新令牌Refresh Token是用于Access Token访问令牌过期的时候,不用从头开始认证,直接拿着Refresh Token去认证服务,认证服务直接颁发一个新的访问令牌Access Token给客户端,客户端带着新的访问令牌访问资源服务器的资源。
Spring Security + OAuth2.0
授权服务器
授权服务器中有4个端点。说明如下:
- Authorize Endpoint :授权端点,进行授权。
- Token Endpoint :令牌端点,经过授权拿到对应的Token。
- lntrospection Endpoint :校验端点,校验Token的合法性。
- Revocation Endpoint :撤销端点,撤销授权。
Spring Security Oauth2架构
说明如下:
- 用户访问,此时没有Token。Oauth2RestTemplate会报错,这个报错信息会被 Oauth2ClientContextFilter捕获并重定向到认证服务器。
- 认证服务器通过Authorization Endpoint进行授权,并通过AuthorizationServerTokenServices生成授权码并返回给客户端。
- 客户端拿到授权码去认证服务器通过Token Endpoint调用AuthorizationServerTokenServices生成Token并返回给客户端。
- 客户端拿到Token去资源服务器访问资源,一般会通过Oauth2AuthenticationManager调用ResourceServerTokenServices进行校验。校验通过可以获取资源。
Spring Security Oauth2授权码模式
4.3.1、环境搭建
(1)创建项目
包结构如上图。
(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:8080:这是项目端口。
- /oauth/authorize?response_type=code:获取授权码的固定写法。
- client_id:这是客户端ID,就是在授权服务中定义的:
- redirect_uri:重定向的url。
- scope:授权范围。 访问后: 用户名随便写,只要密码正确就可以。 勾选,然后授权。 重定向到了百度首页,并且拿到了授权码。
(2)获取令牌
因为要发送post请求,所以使用postman。
url:
左边的type选择Basic Auth,右边的用户名为客户端ID,密码是定义好的。
body里面选择表单,需要携带5个参数。参数说明如下:
- grant_type :授权类型,填写authorization_code,表示授权码模式。
- code :授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。
- client_id:客户端标识。
- redirect_uri :申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。
- scope:授权范围。
发送请求,返回如下:
通行令牌access_token拿到了。如果授权码不对,是拿不到令牌的,如下:
只有授权码正确,服务端才会返回通行令牌,注意。
(3)获取资源服务器资源
需要携带通行令牌来获取。还是post请求:
左边选择Bearer Token,右边Token里面填刚才返回的Token。
发送请求,返回结果如下:
获取到了用户信息。然后将令牌修改成错误的,再发送:
无效令牌,拒绝访问。只有携带正确的令牌才能访问资源。
Spring Security Oauth2密码模式
环境搭建
直接在授权码模式的基础上进行修改了。
(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)获取资源
现在直接可以携带令牌去访问资源:
发送,返回结果如下:
获取资源成功。
Redis中存储Token令牌
将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配置类 */ @Configuration public class RedisConfig { //工厂 @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean public TokenStore reidsTokenStore() { return new RedisTokenStore(redisConnectionFactory); } }
(4)修改授权服务配置
修改AuthorizationServerConfig,如下:
标记的地方是添加的。
(5)测试
启动工程,使用密码模式获取令牌:
发送,返回结果:
查看redis:
存的token和和返回的token是一致的。
这里存的是客户端ID。
这里存的是用户名。
JWT常见的认证机制
HTTP Basic Auth
HTTP Basic Auth简单点说明就是每次请求API时都提供用户的username和password,简言之,Basic Auth是配合RESTful API 使用的最简单的认证方式,只需提供用户名密码即可,但由于有把用户名密码暴露给第三方客户端的风险,在生产环境下被使用的越来越少。因此,在开发对外开放的RESTful API时,尽量避免采用HTTP Basic Auth。
5.1.2、Cookie Auth
Cookie认证机制就是为一次请求认证在服务端创建一个Session对象,同时在客户端的浏览器端创建了一个Cookie对象;通过客户端带上来Cookie对象来与服务器端的session对象匹配来实现状态管理的。默认的,当我们关闭浏览器的时候,cookie会被删除。但可以通过修改cookie expire time使cookie在一定时间内有效。
5.1.3、OAuth
OAuth(开放授权,Open Authorization)是一个开放的授权标准,允许用户让第三方应用访问该用户在某一web服务上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。如网站通过微信、微博登录等,主要用于第三方登录。
OAuth允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的第三方系统(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。
这种基于OAuth的认证机制适用于个人消费者类的互联网产品,如社交类APP等应用,但是不太适合拥有自有认证权限管理的企业应用。缺点是过重。
5.1.4、Token Auth
流程如下:
- 客户端使用用户名跟密码请求登录。
- 服务端收到请求,去验证用户名与密码。
- 验证成功后,服务端会签发一个Token,再把这个Token发送给客户端。
- 客户端收到Token以后可以把它存储起来,比如放在Cookie里。
- 客户端每次向服务端请求资源的时候需要带着服务端签发的Token。
- 服务端收到请求,然后去验证客户端请求里面带着的Token,如果验证成功,就向客户端返回请求的数据。
这种方式比第一种方式更安全,比第二种方式更节约服务器资源,比第三种方式更加轻量。
Token机制相对于Cookie机制的优点如下:
- 支持跨域访问 Cookie是不允许垮域访问的,这一点对Token机制是不存在的,前提是传输的用户 认证信息通过HTTP头传输。
- 无状态(也称:服务端可扩展行) Token机制在服务端不需要存储session信息,因为Token自身包含了所有登录用户的信息,只需要在客户端的cookie或本地介质存储状态信息。
- 更适用CDN 可以通过内容分发网络请求你服务端的所有资料(如:javascript,HTML,图片 等),而你的服务端只要提供API即可。
- 去耦 不需要绑定到一个特定的身份验证方案。Token可以在任何地方生成,只要在你的API被调用的时候,你可以进行Token生成调用即可。
- 更适用于移动应用 当你的客户端是一个原生平台(iOS, Android,Windows 10等)时,Cookie 是不被支持的(你需要通过Cookie容器进行处理),这时采用Token认证机制就会简单得多。
- CSRF 因为不再依赖于Cookie,所以你就不需要考虑对CSRF(跨站请求伪造)的防范。
- 性能 一次网络往返时间(通过数据库查询session信息)总比做一次HMACSHA256计算的Token验证和解析要费时得多。
- 不需要为登录页面做特殊处理 如果你使用Protractor 做功能测试的时候,不再需要为登录页面做特殊处理。
- 基于标准化 你的API可以采用标准化的JSON Web Token (JWT)这个标准已经存在多个后端库 (.NET,Ruby,Java,Python,PHP)和多家公司的支持(如:Firebase,Google, Microsoft)。
JWT简介
JWT的概念
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。
JWT的优缺点
优点:
- jwt基于json,非常方便解析。
- 可以在令牌中自定义丰富的内容,易扩展。
- 通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
- 资源服务使用JWT可不依赖认证服务即可完成授权。
缺点:
JWT令牌较长,占存储空间比较大。
5.2.3、JWT组成
一个JWT实际上就是一个字符串,它由三部分组成:头部、负载和签名。
(1)头部(Header)
第一部分是头部,头部用于描述关于该JWT的最基本的信息,例如其类型(即JWT)以及签名所用的算法(如HMAC SHA256或RSA)等。这也可以被表示成一个JSON对象。
说明:
- alg :签名的算法,这里使用的算法是HS256算法。
- typ :是类型。
我们对头部的json字符串进行BASE64编码,编码后的字符串如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Base64是一种基于64个可打印字符来表示二进制数据的表示方法。由于2的6次方等于64,所以每6个比特为一个单元,对应某个可打印字符。三个字节有24个比特,对应于4个Base64单元,即3个字节需要用4个可打印字符来表示。JDK 中提供了非常方便的 BASE64Encoder 和 BASE64Decoder ,用它们可以非常方便的完成基于 BASE64 的编码和解码。
(2)负载(Payload)
第二部分是负载,就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分,如下:
标准中注册的声明(建议但不强制使用)
- iss:jwt签发者。
- sub:jwt所面向的用户。
- aud:接收jwt的一方。
- exp:jwt的过期时间,这个过期时间必须要大于签发时间。
- nbf:定义在什么时间之前,该jwt都是不可用的.。
- iat:jwt的签发时间。
- jti:jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
公共的声明
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密。
私有的声明
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为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使用
JJWT是一个提供端到端的JWT创建和验证的Java库。永远免费和开源(Apache License,版本2.0),JJWT很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。
以下通过一个demo来快速入门。
(1)创建工程
(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.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() // 唯一ID{"ID":"888"} .setId("888")//声明的标识 //接受的用户{“sub”:"Rose"} .setSubject("Rose")//主体 //签发时间{“iat”:"...."} .setIssuedAt(new Date())//创建日期 //签名,第一个参数是算法,第二个参数是盐 .signWith(SignatureAlgorithm.HS256, "xx123"); //获取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.eyJqdGkiOiI4ODgiLCJzdWIiOiJSb3NlIiwiaWF0IjoxNjEzMDQ3Mzg2fQ.VuCUox9DlADPNZV3nvhoBk5hvezQn-GfspSOlCemDh4"; //解析token,获取负载中的声明对象 Claims claims = Jwts.parser() .setSigningKey("xx123")//这个就是秘钥 .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); }
执行这个方法,控制台:
(5)token过期校验
有很多时候,我们并不希望签发的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, "xx123") .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", "重庆"); //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); }
执行,控制台:
或者这样写也行:
@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); }
执行,控制台:
SpringSecurity + OAuth2.0 + JWT
前面只使用Oauth2.0的话,颁发的通行令牌长度太短了,现在想整合JWT,将颁发的token转换一下,转换成jwt格式的长令牌。
整合JWT
直接在此工程的基础上修改了。
(1)pom注释掉redis有关的依赖。
(2)Redis配置类注释掉Redis的配置类。
(3)Jwt配置类
在config包下创建JwtTokenStoreConfig配置类,如下:
/* * Jwt配置类 */ @Configuration public class JwtTokenStoreConfig { //注册JwtAccessTokenConverter @Bean public JwtAccessTokenConverter jwtAccessTokenConverter() { JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter(); //配置jwt秘钥 jwtAccessTokenConverter.setSigningKey("xx123"); return jwtAccessTokenConverter(); } //注册TokenStore @Bean public TokenStore jwtTokenStore() { return new JwtTokenStore(jwtAccessTokenConverter()); } }
(4)修改授权配置类
修改AuthorizationServerConfig类,如下:
标记的地方是添加或修改的。
(5)测试
使用密码模式获取jwt令牌,如下:
返回结果如下:
现在的access_token令牌的长度发生了变化,与它对应的是jti值。解析这个token值:
没问题,解析的结果也是正确的。
扩展JWT的内容
现在想往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的内容
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("xx123".getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody(); return claims; } }
(3)测试
先获取jwt令牌:
然后带着jwt令牌去获取资源:
请求头Header里面带一个参数Authorization,值为bearer后面加一个空格,然后跟jwt令牌内容,发送,返回结果:
获取到了,返回的是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小时,再用这个新的通行令牌获取资源:
资源获取成功。
SpringSecurity + OAuth2.0 + JWT整合SSO
SSO单点登录
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
如下图:
如上图,假设有4个子系统。现在用户首次登录子系统1:
- 用户使用浏览器登录子系统1,子系统1请求认证系统对用户的身份进行认证。
- 认证通过后,认证服务会返回一个token给浏览器,浏览器一般将这个token存在Cookie中。
- 用户继续在浏览器中登录子系统2,那么这时会带着Cookie中的token直接去认证系统进行认证。
- 认证系统认证通过,用户无需再次输入账号密码登录,可以直接进入子系统2。登录其他子系统也是一样的。
通过以上过程可以看到,用户只需要成功登录一次,就可以不用再进行登录而直接进入其他子系统。
测试
(1)创建工程
创建一个新的工程模拟子系统:
(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.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的名称。再修改一下跳转的uri:
添加了一个跳转的uri。重新启动这3个工程,访问如下:
跳到了登录页面:
输入账号密码登录:
成功获取到了资源。再通过第二个子系统访问资源:
成功访问到了资源。可以看到子系统2并没有再进行登录,而是直接可以访问资源,这就是单点登录SSO,只用成功登录一次便可以在所有子系统中通行。再看Cookie:
成功的添加了一个名称为session02的Cookie。
8、总结
通过学习,掌握Spring Security、Oauth2.0、JWT的用法。以及整合Spring Security + Oauth2.0 + JWT进行SSO单点登录。
发送请求,返回结果如下:
access_token令牌长度变长了,而且下面是我添加的自定义的内容。解析生成的jwt令牌:
令牌中包含添加的自定义内容,但是后面的乱码了。