目录 前言 为什么要登录 登录的种类 Cookie-Session Cookie-Session-local storage JWT令牌 几种登陆总结 用户身份认证与授权 创建工程 添加依赖 启动项目 Bcrypt算法的工具
目录
登录这东西很奇怪哎,你说它难吗?好像客户端只需要调接口就行,那有啥难的?当你多多少少对登录的后台有些了解,又觉得好难啊,session,token,cookie,等等一堆东西,有老的大家都不喜欢用的,有新的一些不太懂的,根据公司项目规模不同,还要考虑成本的问题,真是有些头疼。博主今天推荐的一种登陆方式便是spring Security + Jwt的结合使用,为什么要两者结合呢?Spring Security现在已经很少用了,甚至有些人认为已经废弃了,但是因为Spring Security是Spring系列的东西,Spring对其支持很友好,不,是非常友好。但是我们不想使用他验证后的操作,所以我们要打断这个操作,让JWT工作。下面我们就来了解并手动操作一下吧,本篇还是集成在我们前面的微服务项目中,你也可以另起项目一起来做。
我们平时都知道登录,不知道有没有思考过登录解决的是什么问题?大家会想到,不登录就不能拿到用户信息,一些用户行为和服务就没有办法关联到具体人身上,没错!比如购买行为。但我觉得这个说法不够具体,登录的具体作用应该是拿到用户的权限。
我们说,是人,就有不同的角色,一个男人,可以是儿子,可以是父亲,可以是员工,可以是老板等等。那我们就认为,一个登录体系中,必须要有一张用户表和一张角色表,还有刚刚说的权限表。这三张表之间还需要有表明其关系的关联表。
可以说,这三张表在任何一个登录体系都是必备的,甚至你还可以有临时的用户权限表,他们之间多是多对多的关系,要理清他们之间的关系并不简单。
登录的种类到目前为止所使用的技术大概五六种吧,其之间大同小异,从早期的Cookie-Session到现在的单点登录,中间跨越的时间不短,其中有一个时间分割点就是HTML5标准出现的时候,他带来了local storage,使得跨域问题得到良好的解决,但我们并不满足于这种方式,于是token技术出现,但是本质上和基于Cookie-Session+local storage的方式没有太大区别。为了解决微服务间的数据同步,基于Token的JWT认证诞生,其中还有一种利用session和Redis的数据共享技术也能实现数据共享,这和token技术也类似。接下来,我们来简单的了解一下这几种登录方式:
这种方式要追溯到html5出现之前,那时只能利用cookie存储SessionId,但cookie在跨域问题上一言难尽,但并不是不能跨域,只是要比我们后面的方法麻烦,有local storage你还用cookie?而且cookie退出站点后就会销毁,这点让人极不能接受。其流程是:
这种方式和以上相似,只是改了几个地方:
左边先行,获取用户信息,生成sessionid,存储在redis,右边访问其他模块,通过sessionid去redis拿用户信息,注意,用户模块和其他模块也会保存sessionid,这就是数据共享,用户量很大的情况会造成数据冗余,不适合用户量特别大的项目,中小型项目可以。对于客户端,sessionid当然是保存在local storage内了,毕竟谁也不想去额外解决跨域的问题。
这种方式是目前使用比较多的一种方式,它和上面的方式也有相似之处,只是少了数据的存储,JWT不存储session这些东西,它只负责验证jwt是否正确,验证的过程就是解码的过程,关于JWT的标准制式的解释,请大家手动百度吧,不再赘述,博主也记不住,贴了浪费篇幅。看看,大概知道是怎么做的就行。
此处必须有图:
服务端不保存信息,这一点可以节省空间,谁的信息谁自己保存,解密方式在我这里,同时提高了安全性,何乐不为?
如果细分还能再分出几种登录方式,但基本大同小异,博主合并了其中相似的登录方式,总结出来这三种,此处忽略第三方登录,可自行了解。肯定还有其他方式,但总的来说,和这三种应该是类似,并不会完全不同。看了一篇OAuth2.0单点登录相关的文章,还有一篇总结登录的文章,真是写的太好了,分享给大家:
Java——项目常用登录方式详解_new 海绵宝宝()的博客-CSDN博客
里面总结的很全面,也有一些案例,初学者可以看看。
从这里开始,就是我们的项目时间,首先出场的是Spring Security,它是用于解决认证与授权的框架。Spring Security有默认的登录账号和密码,用户名user,密码是随机的,每次启动项目都会重新生成一个。它要求所有的请求都必须先登录才允许访问,稍后我们集成后可以来进行测试。
在微服务项目cloud下创建cloud-passport子项目:
4.0.0 com.codingfire cloud 0.0.1-SNAPSHOT com.codingfire cloud-passport 0.0.1-SNAPSHOT cloud-passport Demo project for Spring Boot org.springframework.boot spring-boot-starter-WEB org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-test test
父子关联
cloud-commons cloud-bussiness cloud-cart cloud-order cloud-stock gateway search cloud-passport
依赖添加完毕,什么都不需要做,直接运行passport的启动文件,可以在控制台看到如下输出:
Using generated security passWord: 1060ee9f-a56e-4ff5-bce4-68306b3265b1
这就是Spring Security生成的随机密码,它同时还提供了一个URL:http://localhost:8080/login
我们点开URL,在浏览器打开一个登录页面,我们输入用户名:user,密码就用上面的密码,登录成功后跳转回之前访问的URL,由于我们没有做这个页面,会显示404。这就是Spring Security默认要求所有的请求都是必须先登录才允许的访问的能力。
Spring Security的依赖项中包括了Bcrypt算法的工具类,这是一款非常优秀的密码加密工具,适和对需要存储下来的密码进行加密处理。我们来测试下看看。
打开测试类,添加如下测试代码:
package com.codingfire.cloud.passport;import org.junit.jupiter.api.Test;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;@SpringBootTestclass CloudPassportApplicationTests { private BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); @Test public void testEncode() { // 原文相同的情况,每次加密得到的密文都不同 for (int i = 0; i < 10; i++) { String rawPassword = "123456"; String encodedPassword = passwordEncoder.encode(rawPassword); System.out.println("rawPassword = " + rawPassword); System.out.println("encodedPassword = " + encodedPassword); } } @Test public void testMatches() { String rawPassword = "123456"; String encodedPassword = "$2a$10$4LHozWwptKuabvikrzM1KefYFgI7H4A9xCVv7cvMKsV9ycS4guS5K"; boolean matchResult = passwordEncoder.matches(rawPassword, encodedPassword); System.out.println("match result : " + matchResult); }}
我们分别运行这两个方法,会看到如下输出:
rawPassword = 123456encodedPassword = $2a$10$4LHozWwptKuabvikrzM1KefYFgI7H4A9xCVv7cvMKsV9ycS4guS5K rawPassword = 123456encodedPassword = $2a$10$VA9u7X9rSVuetPlEixhnSujHdVsK8OwqkVIOqLzNydxa.ypCviVIq rawPassword = 123456encodedPassword = $2a$10$d9lWItH5YhEFRns/Yj5U3OUyHM8rLKAE9X.SsbcIOA0WwRqUwFl82 rawPassword = 123456encodedPassword = $2a$10$W/PLc/Q04.8xfmEQgwSKC.g79FxRPJGFXRuFzISdVrn3cYWk1xkye rawPassword = 123456encodedPassword = $2a$10$/9Ya1aqjQBX8342iH5blTOZeHJomKUitInVmLTsANonXriQjxhb5K rawPassword = 123456encodedPassword = $2a$10$kX2u5zLrDN/VC8CLVRGmsOIFqA2FHCJRYJKnYmWeu/NyTQEjBCbki rawPassword = 123456encodedPassword = $2a$10$igB96QfY9XDwhPz3U8Z7Nui1UQy.wtzSl9uk2n7m.lCdcKwhGqLXu rawPassword = 123456encodedPassword = $2a$10$ssDypFmm0bN0CvIBqoB4huHIhT7oRwS9KsO1iopyFeSOUWYR96NPC rawPassword = 123456encodedPassword = $2a$10$IWBuDVLYjvHCUqOM9qAQuu.kTlW8RH08CbIFlvYTzcdEMLHbVSFtS rawPassword = 123456encodedPassword = $2a$10$J/eN5/loO6DTJG7ubgQh4.1ovwI9CS1H0yqnsbYEQFwnvqRq64bU.
match result : true
下面的解密使用上面的第一个加密后的密文进行的解密。大家要用自己的电脑生成的密文进行解密,用博主的可能会出现无法匹配的情况。
此加密工具有个特点,大家应该发现了,此加密得到的密文都不相同。
接着需要和数据库中存储的密文进行对比,此时需要使用sql去数据库查询该用户的密文进行比对,比对通过,则可进行登录。此时就不能使用默认的user
用户名和随机的密码的方式,具体做法我们继续往下看。
在commons工程下创建pojo.passport.vo.AdminLoginVO类:
package com.codingfire.cloud.commons.pojo.passport.vo;import lombok.Data;import java.io.Serializable;import java.util.List;@Datapublic class AdminLoginVO implements Serializable { private Long id; private String username; private String password; private Integer isLogin; private List permissions;}
创建完成后我们发现要使用commons模块,那需要依赖添加此模块:
com.codingfire cloud-commons 0.0.1-SNAPSHOT
在passport下创建mapper.AdminMapper
接口:
package com.codingfire.cloud.passport.mapper;import com.codingfire.cloud.commons.pojo.passport.vo.AdminLoginVO;public interface AdminMapper { AdminLoginVO getLoginInfoByUsername(String username);}
大家还记得吗?我们在mybatis框架中有使用XML文件来写SQL。在src/main/resources下创建mapper文件夹,mapper文件夹下可以把前面的xml文件粘贴过来,写入如下SQL:
select from admin left join admin_role on admin.id = admin_role.admin_id left join role_permission on admin_role.role_id = role_permission.role_id left join permission on role_permission.permission_id = permission.id where username=#{username} admin.id, admin.username, admin.password, admin.is_login, permission.name
在这里大家要留意几个问题了,我们这里需要连接mybatis的数据库,第一次看博主文章的需要看看Java开发 - Mybatis框架初体验_CodingFire的博客-CSDN博客
这篇博客,才知道建的什么数据库, 有哪些表,有哪些参数,否则将很难进行下去。
由于需要使用数据库,需要补充配置和依赖。
org.mybatis.spring.boot mybatis-spring-boot-starter com.alibaba druid Mysql mysql-connector-java
这里,我们选择从mybatis复制配置信息到properties文件:
spring.datasource.url=jdbc:mysql://localhost:3306/mybatis?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=truespring.datasource.driver=com.mysql.cj.jdbc.Driverspring.datasource.username=rootspring.datasource.password=0mybatis.mapper-locations=classpath:mapper/AdminMapper.xml
密码写自己的数据库密码。
需要连接数据库,那么少不了mybatis配置了,创建MybatisConfiguration类,在passport下创建config包,此包下创建配置类:
package com.codingfire.cloud.passport.config;import org.springframework.context.annotation.ComponentScan;import org.springframework.context.annotation.Configuration;@Configuration@ComponentScan("com.codingfire.cloud.passport.mapper")public class MybatisConfiguration {}
前面也是有创建过的,你可以选择直接贴过来,但要注意扫描的路径改成自己的路径。原本需要在配置文件中配置mybatis.mapper-locations
属性,上面已经补充过了。
在测试类下,我们添加如下代码:
@Autowired AdminMapper adminMapper; @Test void selectUser() { AdminLoginVO adminLoginVO = adminMapper.getLoginInfoByUsername("admin04"); System.out.println(adminLoginVO); }
这是我们原来表中的数据,没有数据的需要预先插入一些数据。运行测试方法,发现报错?
额,一大堆,看了......好一会儿,才发现有两个地方写错了:
一个是AdminMapper内没有添加@Repository注解:
另一个是MybatisConfiguration类上的scan注解写错了,修改一下:
然后再次运行测试方法,可以在控制台看到输出的用户信息如下:
AdminLoginVO(id=1, username=admin04, password=123456, isLogin=0, permissions=[全频道可删除, 全频道可筛选, 全频道读取, 单频道可删除, 单频道可筛选, 单频道观看])
代表我们的测试成功了。简直累的一逼,真是错一步都不行。
前面提过,要让Spring Security通过数据库的数据来验证用户名与密码,我们还需要做出一些修改和配置,我们看到每次控制台都会输出一串新的密码:
Using generated security password: a47b9983-3ea3-45d8-9632-faf701a7925b
下面,让我们看看怎样才能不让它输出。
在config包下创建SecurityConfiguration类:
package com.codingfire.cloud.passport.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;@Configurationpublic class SecurityConfiguration { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }}
在passport下建新包security,包下建类UserDetailsServiceImpl,并实现UserDetailsService接口:
package com.codingfire.cloud.passport.security;import com.codingfire.cloud.commons.pojo.passport.vo.AdminLoginVO;import com.codingfire.cloud.passport.mapper.AdminMapper;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.authentication.BadCredentialsException;import org.springframework.security.core.userdetails.User;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.core.userdetails.UsernameNotFoundException;@Servicepublic class UserDetailsServiceImpl implements UserDetailsService { @Autowired private AdminMapper adminMapper; @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { System.out.println("根据用户名查询尝试登录的管理员信息,用户名=" + s); AdminLoginVO admin = adminMapper.getLoginInfoByUsername(s); System.out.println("通过持久层进行查询,结果=" + admin); if (admin == null) { System.out.println("根据用户名没有查询到有效的管理员数据,将抛出异常"); throw new BadCredentialsException("登录失败,用户名不存在!"); } System.out.println("查询到匹配的管理员数据,需要将此数据转换为UserDetails并返回"); UserDetails userDetails = User.builder() .username(admin.getUsername()) .password(admin.getPassword()) .accountExpired(false) .accountLocked(false) .disabled(admin.getIsLogin() != 1) .credentialsExpired(false) .authorities(admin.getPermissions().toArray(new String[] {})) .build(); System.out.println("转换得到UserDetails=" + userDetails); return userDetails; }}
重新启动工程,看看还有没有随机密码生成:
可以看到,随机密码已经不会再自动生成。
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON
的开放标准((RFC 7519).定义了一种简洁的,自包含的方法用于通信双方之间以jsON
对象的形式安全的传递信息。因为数字签名的存在,这些信息是可信的,JWT可以使用HMac
算法或者是RSA
的公私秘钥对进行签名。
客户端第1次访问服务器端时,是没有携带令牌访问的,当服务器进行响应时,会将JWT响应到客户端,客户端保存后,在第2次访问时就开始携带JWT进行请求,服务器收到请求中的JWT后就可以识别用户身份。
关于JWT的详细介绍,推荐这篇博客:SpringBoot集成JWT实现token验证 - 简书
Spring Security默认使用Session机制存储用户信息,而Http协议是无状态协议,它不保存客户端信息,所以,同一个客户端的多次访问,等效于多个不同的客户端各访问一次服务端,为了保存用户信息,使服务器端能够识别客户端身份,我们推荐使用Token或其他技术,比如我们马上要说的JWT。
JWT只是一个概念,而实现生成JWT、解析JWT的框架却有不少,我们这里要使用的是jjwt,添加依赖如下:
io.jsonwebtoken jjwt
由于版本已经在主项目中控制,此处版本省略。
在测试类下创建JwtTests类,添加如下测试代码:
// 密钥,遵从越长越好,越乱越复杂越好的原则 String secreTKEy = "asjdkahwehuqdyaoisdqwuphdabskbkansdjashdjasdh"; @Test public void testGenerateJwt() { // Claims Map claims = new HashMap<>(); claims.put("id", 01); claims.put("name", "codingfire"); // JWT的组成部分:Header(头),Payload(载荷),Signature(签名) String jwt = Jwts.builder() // Header:指定算法与当前数据类型 // 格式为: { "alg": 算法, "typ": "jwt" } .setHeaderParam(Header.CONTENT_TYPE, "HS256") .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Payload:通常包含Claims(自定义数据)和过期时间 .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000)) // Signature:由算法和密钥(secret key)这2部分组成 .signWith(SignatureAlGorithm.HS256, secretKey) // 打包生成 .compact(); System.out.println(jwt); }
运行测试方法,输出加密后的密文如下:
eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoiY29kaW5nZmlyZSIsImlkIjoxLCJleHAiOjE2Nzc3MzgyNzZ9.cz_cjbIT28GgZ5gQFkgOEAVMQjqFRW2MIliGftfT2As
你能看到里面有两个点,这是JWT加密的固定格式,需要你去看推荐的博文。
接着我们把这串密文用来解密试试看能得到什么:
@Test public void testParseJwt() { String jwt = "eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJuYW1lIjoiY29kaW5nZmlyZSIsImlkIjoxLCJleHAiOjE2Nzc3MzgyNzZ9.cz_cjbIT28GgZ5gQFkgOEAVmqjqFRW2MIliGftfT2As"; Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody(); Object id = claims.get("id"); Object name = claims.get("name"); System.out.println("id=" + id); System.out.println("name=" + name); }
运行测试方法:
看到如图所示结果,你的jwt就已经引入成功。但,这还不够,我们是要在Spring Security中使用JWT,所以还有很多工作要做。
AuthenticationManager
对象这是一个认证管理器,我们需要接管这个管理器,在SecurityConfiguration类中做一些操作,来看看最终的SecurityConfiguration类吧:
package com.codingfire.cloud.passport.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.config.annotation.web.builders.httpsecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;@Configurationpublic class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { // 禁用防跨域攻击 http.csrf().disable(); // URL白名单 String[] urls = { "/admins/login" }; // 配置各请求路径的认证与授权 http.authorizeRequests() // 请求需要授权才可以访问 .antMatchers(urls) // 匹配一些路径 .permitAll() // 允许直接访问(不需要经过认证和授权) .anyRequest() // 匹配除了以上配置的其它请求 .authenticated(); // 都需要认证 }}
在上面创建AdminLoginVO类的地方创建新的包dto,下面建新类:
在passport下创建service包,其下创建新接口类IAdminService:
package com.codingfire.cloud.passport.service;import com.codingfire.cloud.commons.pojo.passport.dto.AdminLoginDTO;public interface IAdminService { String login(AdminLoginDTO adminLoginDTO);}
在service包下创建新包impl,其下创建实现类AdminServiceImpl:
package com.codingfire.cloud.passport.service.impl;import com.codingfire.cloud.commons.pojo.passport.dto.AdminLoginDTO;import com.codingfire.cloud.passport.service.IAdminService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.Authentication;import org.springframework.stereotype.Service;@Servicepublic class AdminServiceImpl implements IAdminService { @Autowired private AuthenticationManager authenticationManager; @Override public String login(AdminLoginDTO adminLoginDTO) { // 生成此用户数据的JWT String jwt = "This is a JWT."; // 临时 return jwt; }}
在passport下创建controller包,其下创建AdminController:
package com.codingfire.cloud.passport.controller;import com.codingfire.cloud.commons.pojo.passport.dto.AdminLoginDTO;import com.codingfire.cloud.passport.service.IAdminService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController@RequestMapping(value = "/admins", produces = "application/json; charset=utf-8")public class AdminController { @Autowired private IAdminService adminService; @RequestMapping("/login") public String login(AdminLoginDTO adminLoginDTO) { String jwt = adminService.login(adminLoginDTO); return jwt; }}
启动项目,在浏览器输入:http://localhost:8080/admins/login?username= codingfire&password=123456
把用户名和密码改成你自己数据库中的用户名和密码,也可以写错的,然后进行多次尝试,看浏览器会返回什么:
看到此信息,就代表你的jwt接入成功了,但我们需要返回给客户端jwt数据,接下来我们实现这个过程。
package com.codingfire.cloud.passport.service.impl;import com.codingfire.cloud.commons.pojo.passport.dto.AdminLoginDTO;import com.codingfire.cloud.passport.service.IAdminService;import io.jsonwebtoken.Header;import io.jsonwebtoken.Jwts;import io.jsonwebtoken.SignatureAlgorithm;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.Authentication;import org.springframework.security.core.userdetails.User;import org.springframework.stereotype.Service;import java.util.Date;import java.util.HashMap;import java.util.Map;@Servicepublic class AdminServiceImpl implements IAdminService { @Autowired private AuthenticationManager authenticationManager; @Override public String login(AdminLoginDTO adminLoginDTO) { // 密钥,遵从越长越好,越乱越复杂越好的原则 String secretKey = "asjdkahwehuqdyaoisdqwuphdabskbkansdjashdjasdh"; // 准备被认证数据 Authentication authentication = new UsernamePasswordAuthenticationToken( adminLoginDTO.getUsername(), adminLoginDTO.getPassword()); // 调用AuthenticationManager验证用户名与密码 // 执行认证,如果此过程没有抛出异常,则表示认证通过,如果认证信息有误,将抛出异常 authenticationManager.authenticate(authentication); User user = (User) authentication.getPrincipal(); System.out.println("从认证结果中获取Principal=" + user.getClass().getName()); Map claims = new HashMap<>(); claims.put("username", user.getUsername()); claims.put("permissions", user.getAuthorities()); System.out.println("即将向JWT中写入数据=" + claims); // JWT的组成部分:Header(头),Payload(载荷),Signature(签名) String jwt = Jwts.builder() // Header:指定算法与当前数据类型 // 格式为: { "alg": 算法, "typ": "jwt" } .setHeaderParam(Header.CONTENT_TYPE, "HS256") .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Payload:通常包含Claims(自定义数据)和过期时间 .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000)) // Signature:由算法和密钥(secret key)这2部分组成 .signWith(SignatureAlgorithm.HS256, secretKey) // 打包生成 .compact(); // 返回JWT数据 return jwt; }}
你会发现,这就是我们在测试类中测试的代码,基本上是直接贴过来的。
package com.codingfire.cloud.passport.controller;import com.codingfire.cloud.commons.pojo.passport.dto.AdminLoginDTO;import com.codingfire.cloud.commons.restful.JsonResult;import com.codingfire.cloud.passport.service.IAdminService;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController@RequestMapping(value = "/admins", produces = "application/json; charset=utf-8")public class AdminController { @Autowired private IAdminService adminService; @RequestMapping("/login") public JsonResult login(AdminLoginDTO adminLoginDTO) { String jwt = adminService.login(adminLoginDTO); return JsonResult.ok(jwt); }}
修改返回值类型。
运行项目,在浏览器输入原来的html: http://localhost:8080/admins/login?username= codingfire&password=123456
浏览器将得到如下数据:
{"state":200,"message":"eyJjdHkiOiJIUzI1NiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJwZXJtaXNzaW9ucyI6Ilt7XCJhdXRob3JpdHlcIjpcIuWFqOmikemBk-WPr-WIoOmZpFwifSx7XCJhdXRob3JpdHlcIjpcIuWFqOmikemBk-WPr-etm-mAiVwifSx7XCJhdXRob3JpdHlcIjpcIuWFqOmikemBk-ivu-WPllwifSx7XCJhdXRob3JpdHlcIjpcIuWNlemikemBk-WPr-WIoOmZpFwifSx7XCJhdXRob3JpdHlcIjpcIuWNlemikemBk-WPr-etm-mAiVwifSx7XCJhdXRob3JpdHlcIjpcIuWNlemikemBk-ingueci1wifV0iLCJleHAiOjE2Nzc3NDkzOTUsInVzZXJuYW1lIjoiY29kaW5nZmlyZSJ9.dw4tk52xTXXQ4-D_qkZNhjL-RkHnzG6QKHe6Tq1j3_Y","data":null}
这里有个坑啊小伙伴们,如果你一直403,且控制台提示你Encoded password does not look like BCrypt,这是因为你的数据库存储的是明文密码,必须存储我们在测试类中使用BCryptPasswordEncoder加密后的密码。博主刚刚就犯了这个错,真实太容易忽略了,不知道该说啥,大家可别犯这个错。
刚刚由于我们禁止了未登陆时直接进入Spring Security的登陆页,所以才需要添加了白名单解决屏蔽所有连接的问题。如果使用Knife4j,该怎么添加白名单呢?我们来看看:
String[] urls = { "/admins/login", "/doc.html", // 从本行开始,以下是新增 "*.js", "*.CSS", "/swagger-resources", "/v2/api-docs", "/favicon.ico" };
得到JWT之后,在后续的请求中都需要在请求头中带上JWT,放在Authorization属性内,所以应该先判断请求头中是否有Authorization,而不能让请求直达服务器业务模块。这让我想到了前面讲过的过滤器,下面,我们在security包下创建一个过滤器类:
package com.codingfire.cloud.passport.security;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;import javax.servlet.FilterChain;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;@Componentpublic class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { System.out.println("JwtAuthenticationFilter.doFilterInternal()"); }}
过滤器类是需要注册后才能工作的,所以下一步对过滤器进行注册。用于验证JWT的过滤器应该运行在Spring Security处理登录的过滤器之前才能工作,所以需要在自定义的SecurityConfiguration
中的configure()
方法中将我们自定义的过滤器注册在Spring Security的相关过滤器之前。
同一个项目中允许存在多个过滤器,形成过滤器链,所以我们注册过滤器不需要单独建个类来处理了,而是在SecurityConfiguration类中进行,最终的类如下:
package com.codingfire.cloud.passport.config;import com.codingfire.cloud.passport.security.JwtAuthenticationFilter;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;@Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { // 禁用防跨域攻击 http.csrf().disable(); // URL白名单 String[] urls = { "/admins/login", "/doc.html", // 从本行开始,以下是新增 "*.js", "*.css", "/swagger-resources", "/v2/api-docs", "/favicon.ico" }; // 配置各请求路径的认证与授权 http.authorizeRequests() // 请求需要授权才可以访问 .antMatchers(urls) // 匹配一些路径 .permitAll() // 允许直接访问(不需要经过认证和授权) .anyRequest() // 匹配除了以上配置的其它请求 .authenticated(); // 都需要认证 // 注册处理JWT的过滤器 // 此过滤器必须在Spring Security处理登录的过滤器之前 http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); }}
我们重起项目,输入之前的url,不太对啊,下载了一个空的login文件,控制台看到了如下内容:
JwtAuthenticationFilter.doFilterInternal()
那是因为过滤器的工作还没有结束,他还需要实现以下功能:
Authentication
对象中,Spring Security的上下文中存储的数据类型就是Authentication
类型Authentication
,在此过滤器执行的第一时间,应该先清除上一次的数据下面,我们来看看自定义过滤器中还有哪些代码:
package com.codingfire.cloud.passport.security;import com.alibaba.fastjson.JSON;import com.codingfire.cloud.commons.restful.JsonResult;import com.codingfire.cloud.commons.restful.ResponseCode;import io.jsonwebtoken.*;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.Authentication;import org.springframework.security.core.authority.SimpleGrantedAuthority;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.stereotype.Component;import org.springframework.util.StringUtils;import org.springframework.web.filter.OncePerRequestFilter;import javax.servlet.FilterChain;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.util.List;@Componentpublic class JwtAuthenticationFilter extends OncePerRequestFilter { private String secretKey = "fgfdsfadsfadsafdsafdsfadsfadsfdsafdasfdsafdsafdsafds4rttrefds"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { System.out.println("JwtAuthenticationFilter.doFilterInternal()"); // 清除Spring Security上下文中的数据 // 避免此前曾经存入过用户信息,后续即使没有携带JWT,在Spring Security仍保存有上下文数据(包括用户信息) System.out.println("清除Spring Security上下文中的数据"); SecurityContextHolder.clearContext(); // 客户端提交请求时,必须在请求头的Authorization中添加JWT数据,这是当前服务器程序的规定,客户端必须遵守 // 尝试获取JWT数据 String jwt = request.getHeader("Authorization"); System.out.println("从请求头中获取到的JWT=" + jwt); // 判断是否不存在jwt数据 if (!StringUtils.hasText(jwt)) { // 不存在jwt数据,则放行,后续还有其它过滤器及相关组件进行其它的处理,例如未登录则要求登录等 // 此处不宜直接阻止运行,因为“登录”、“注册”等请求本应该没有jwt数据 System.out.println("请求头中无JWT数据,当前过滤器将放行"); filterChain.doFilter(request, response); // 继续执行过滤器链中后续的过滤器 return; // 必须 } // 注意:此时执行时,如果请求头中携带了Authentication,日志中将输出,且不会有任何响应,因为当前过滤器尚未放行 // 以下代码有可能抛出异常的 // TODO 密钥和各个Key应该统一定义 String username = null; String permissionsString = null; try { System.out.println("请求头中包含JWT,准备解析此数据……"); Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody(); username = claims.get("username").toString(); permissionsString = claims.get("permissions").toString(); System.out.println("username=" + username); System.out.println("permissionsString=" + permissionsString); } catch (ExpiredJwtException e) { System.out.println("解析JWT失败,此JWT已过期:" + e.getMessage()); JsonResult jsonResult = JsonResult.failed( ResponseCode.ERR_JWT_EXPIRED, "您的登录已过期,请重新登录!"); String jsonString = JSON.toJSONString(jsonResult); System.out.println("响应结果:" + jsonString); response.setContentType("application/json; charset=utf-8"); response.getWriter().println(jsonString); return; } catch (MalfORMedJwtException e) { System.out.println("解析JWT失败,此JWT数据错误,无法解析:" + e.getMessage()); JsonResult jsonResult = JsonResult.failed( ResponseCode.ERR_JWT_MALFORMED, "获取登录信息失败,请重新登录!"); String jsonString = JSON.toJSONString(jsonResult); System.out.println("响应结果:" + jsonString); response.setContentType("application/json; charset=utf-8"); response.getWriter().println(jsonString); return; } catch (SignatureException e) { System.out.println("解析JWT失败,此JWT签名错误:" + e.getMessage()); JsonResult jsonResult = JsonResult.failed( ResponseCode.ERR_JWT_SIGNATURE, "获取登录信息失败,请重新登录!"); String jsonString = JSON.toJSONString(jsonResult); System.out.println("响应结果:" + jsonString); response.setContentType("application/json; charset=utf-8"); response.getWriter().println(jsonString); return; } catch (Throwable e) { System.out.println("解析JWT失败,异常类型:" + e.getClass().getName()); e.printStackTrace(); JsonResult jsonResult = JsonResult.failed( ResponseCode.ERR_INTERNAL_SERVER_ERROR, "获取登录信息失败,请重新登录!"); String jsonString = JSON.toJSONString(jsonResult); System.out.println("响应结果:" + jsonString); response.setContentType("application/json; charset=utf-8"); response.getWriter().println(jsonString); return; } // 将此前从JWT中读取到的permissionsString(JSON字符串)转换成Collection extends GrantedAuthority> List permissions = JSON.parseArray(permissionsString, SimpleGrantedAuthority.class); System.out.println("从JWT中获取到的权限转换成Spring Security要求的类型:" + permissions); // 将解析得到的用户信息传递给Spring Security // 获取Spring Security的上下文,并将Authentication放到上下文中 // 在Authentication中封装:用户名、null(密码)、权限列表 // 因为接下来并不会处理认证,所以Authentication中不需要密码 // 后续,Spring Security发现上下文中有Authentication时,就会视为已登录,甚至可以获取相关信息 Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, permissions); SecurityContextHolder.getContext().setAuthentication(authentication); System.out.println("将解析得到的用户信息传递给Spring Security"); // 放行 System.out.println("JwtAuthenticationFilter 放行"); filterChain.doFilter(request, response); }}
你可能在添加了这个类中的代码后会有一些报错,是因为错误码没有提前声明在枚举类,自己手动添加一下。
接着在SecurityConfiguration类上添加一个新的注解
@EnableGlobalMethodSecurity(prePostEnabled = true) // 新增
作用是开启“通过注解配置权限”的功能。
下面,我们来做个测试,在任何你需要设置权限的处理请求的方法上,通过@PreAuthorize
注解来实现通过注解配置权限功能,你可以配置你想要的某种权限:
在AdminController类中添加如下方法:
@GetMapping("/codingfire") @PreAuthorize("hasAuthority('单频道观看')") // 新增 public String codingfire() { return "codingfire"; }
重启项目,使用具有“单频道观看”
权限的用户可以直接访问,不具有此权限的用户则不能访问,将出现403错误,可通过在线文档功能进行测试。
在线文档添加请求头方式:
请求头内的数据使用正常用的登录后返回的JWT数据,登录的用户权限可自己调整,然后访问codingfire接口查看结果。博主就不再贴后续的内容了。
虽然这篇博客结束了,但登录并没有结束,登录的整体逻辑还有不少,关键的部分本文已经全部列出,剩下的就要大家在实战中慢慢叠加了。3w字才码完,可以说是自己又学习了一遍,你会发现,很多东西都是套路的固定的,只有少部分东西是需要自己去写的,那就是涉及业务的部分。希望大家都能有所收获。
来源地址:https://blog.csdn.net/CodingFire/article/details/129292430
--结束END--
本文标题: Java开发 - 单点登录初体验(Spring Security + JWT)
本文链接: https://lsjlt.com/news/390712.html(转载时请注明来源链接)
有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341
2024-04-01
2024-04-03
2024-04-03
2024-01-21
2024-01-21
2024-01-21
2024-01-21
2023-12-23
回答
回答
回答
回答
回答
回答
回答
回答
回答
回答
0