功能简述:JWT+Redis实现单点登录功能的同时,也实现同一个账号只能在一台设备上登录,实现方式并非是建立长连接,因为长连接是比较消耗系统性能的。这里只是简单的redis方式实现。
什么是单点登录?
单点登录的英文名叫做:Single Sign On(简称SSO)。
在最开始的单体架构(或者说单系统)当中,所有的代码都放在一个项目当中,传统的登录流程是
用户登录—>登录校验(校验用户名密码)—>将用户名等信息放入session当中—>成功登录。
这样就可以从session当中获取用户信息来判断是否登录或者登录人是谁
后来,我们为了合理利用资源和降低耦合性,于是把单系统拆分成多个子系统。如果继续使用传统的登录方式。会产生什么问题呢?简单举个例子
我们都知道session是存在于服务器当中的,假如有两个服务 订单服务和支付服务,分别部署在服务器A和服务器B,在订单服务当中,用户进行了登录,服务器A保存了用户的登录信息,用户进行下单访问服务器A,获取用户信息生成订单,然后支付再访问服务器B,这时候,服务器B是没有方法获取到用户信息的。
这样肯定是不行的,当然session共享可以解决这个问题,但是session共享也有许多弊端,许多公司基本不会使用,而是使用主流的JWT做单点登录
简单来说,单点登录就是在多个系统中,用户只需一次登录,各个系统即可感知该用户已经登录。
单点登录流程:
用户登录—>登录校验—>根据用户信息生成token—>响应token给页面—>前端将token放入cookie
校验:将cookie信息放在请求头—>对token进行验证—>得到用户信息
介绍完了单点登录,废话不多说,上代码
pom文件:
<dependencies><!--jwt起步依赖--><dependency><groupId>com.auth0</groupId><artifactId>java-jwt</artifactId><version>3.4.0</version></dependency><!-- https://mvnrepository.com/artifact/eu.bitwalker/UserAgentUtils --><dependency><groupId>eu.bitwalker</groupId><artifactId>UserAgentUtils</artifactId><version>1.21</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-tomcat</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><!--MyBatis-Plus代码生成器需要的依赖,开始--><!-- 持久层 --><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.1.0</version></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-generator</artifactId><version>3.1.0</version></dependency><!--模板引擎--><dependency><groupId>org.apache.velocity</groupId><artifactId>velocity-engine-core</artifactId><version>2.1</version></dependency><!--MyBatis-Plus代码生成器需要的依赖,结束--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- https://mvnrepository.com/artifact/com.alibaba/fastjson --><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.41</version></dependency><!--rabbitmq--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId></dependency></dependencies>
yml文件:
server:
port:8081
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL
username: root
password: root
redis:
database:0
host:127.0.0.1
port:6379
jedis:
pool:
max-active:100
max-idle:10
max-wait:100000
timeout:5000
rabbitmq:
host:127.0.0.1
port:15672
username: guest
password: guest
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
先在config包下配置两个类
RedisConfig:
@Configuration@ConditionalOnClass(RedisOperations.class)@EnableConfigurationProperties(RedisProperties.class)publicclassRedisConfig{@Bean@ConditionalOnMissingBean(name ="redisTemplate")public RedisTemplate<Object, Object>redisTemplate(
RedisConnectionFactory redisConnectionFactory){
RedisTemplate<Object, Object> template =newRedisTemplate<>();//使用fastjson序列化
FastJsonRedisSerializer fastJsonRedisSerializer =newFastJsonRedisSerializer(Object.class);// value值的序列化采用fastJsonRedisSerializer
template.setValueSerializer(fastJsonRedisSerializer);
template.setHashValueSerializer(fastJsonRedisSerializer);// key的序列化采用StringRedisSerializer
template.setKeySerializer(newStringRedisSerializer());
template.setHashKeySerializer(newStringRedisSerializer());
template.setConnectionFactory(redisConnectionFactory);return template;}@Bean@ConditionalOnMissingBean(StringRedisTemplate.class)public StringRedisTemplate stringRedisTemplate(
RedisConnectionFactory redisConnectionFactory){
StringRedisTemplate template =newStringRedisTemplate();
template.setConnectionFactory(redisConnectionFactory);return template;}}
全局拦截器:InterceptorConfig
@ConfigurationpublicclassInterceptorConfigimplementsWebMvcConfigurer{@OverridepublicvoidaddInterceptors(InterceptorRegistry registry){
registry.addInterceptor(authenticationInterceptor()).addPathPatterns("/**");}@Beanpublic AuthenticationInterceptor authenticationInterceptor(){returnnewAuthenticationInterceptor();}}
UserController:
@RestController@RequestMapping("/users")publicclassUsersController{@Resourceprivate UsersService usersService;@Resource
TokenService tokenService;@Resourceprivate RedisUtil redisUtil;//登录@PassToken@PostMapping("/login")public Object login(@RequestBody Users user, HttpServletRequest request){
JSONObject jsonObject=newJSONObject();//根据用户名查询用户信息
Users userForBase=usersService.findByUsername(user);
String ipAddr = IpUtils.getIpAddr(request);if(userForBase==null){
jsonObject.put("message","登录失败,用户不存在");return jsonObject;}else{if(!userForBase.getPassword().equals(user.getPassword())){
jsonObject.put("message","登录失败,密码错误");return jsonObject;}else{
String token = tokenService.getToken(userForBase);
String key = RedisPreEnum.JWT_TOKEN_PRE.getPre()+userForBase.getId();
Set<String> keys = redisUtil.keys(key+"*");if(CollectionUtils.isEmpty(keys)){
redisUtil.set(key+ipAddr,userForBase,RedisPreEnum.JWT_TOKEN_PRE.getExpired());}else{//请空之前的keyfor(String k:keys){
redisUtil.del(k);}//重新设置key
redisUtil.set(key+ipAddr,userForBase,RedisPreEnum.JWT_TOKEN_PRE.getExpired());}
jsonObject.put("token", token);
jsonObject.put("user", userForBase);return jsonObject;}}}@GetMapping("/getMessage")public String getMessage(){return"你已通过验证";}}
TokenService:
@ComponentpublicclassTokenService{privatefinalstatic String SIGN ="";public String getToken(Users user){
Calendar instance = Calendar.getInstance();
instance.add(Calendar.DATE,1);
String token="";
token= JWT.create().withAudience(String.valueOf(user.getId())).withExpiresAt(instance.getTime()).sign(Algorithm.HMAC256(user.getPassword()));return token;}public String verifyToken(String token){return null;}}
RedisUtil:
https://blog.csdn.net/weixin_43412919/article/details/122050884?spm=1001.2014.3001.5501
IpUtils:
package com.utils;import eu.bitwalker.useragentutils.UserAgent;import javax.servlet.http.HttpServletRequest;publicclassIpUtils{//客户端类型 手机、电脑、平板//UserAgent userAgent = UserAgent.parseUserAgentString(request.getHeader("user-agent"));//String clientType = userAgent.getOperatingSystem().getDeviceType().toString();//操作系统类型//String os = userAgent.getOperatingSystem().getName();//请求ip//String ip = IpUtils.getIpAddr(request);//浏览器类型//String browser = userAgent.getBrowser().toString();publicstatic String getIpAddr(HttpServletRequest request){
String ip = request.getHeader("x-forwarded-for");if(ip == null || ip.length()==0||"unknown".equalsIgnoreCase(ip)){
ip = request.getHeader("X-Real-IP");//LOGGER.error("X-Real-IP:"+ip);}if(ip == null || ip.length()==0||"unknown".equalsIgnoreCase(ip)){
ip = request.getHeader("http_client_ip");}if(ip == null || ip.length()==0||"unknown".equalsIgnoreCase(ip)){
ip = request.getRemoteAddr();}if(ip == null || ip.length()==0||"unknown".equalsIgnoreCase(ip)){
ip = request.getHeader("Proxy-Client-IP");}if(ip == null || ip.length()==0||"unknown".equalsIgnoreCase(ip)){
ip = request.getHeader("WL-Proxy-Client-IP");}if(ip == null || ip.length()==0||"unknown".equalsIgnoreCase(ip)){
ip = request.getHeader("HTTP_X_FORWARDED_FOR");}// 如果是多级代理,那么取第一个ip为客户ipif(ip != null && ip.indexOf(",")!=-1){
ip = ip.substring(ip.lastIndexOf(",")+1, ip.length()).trim();}return ip;}}
PassToken注解:
import java.lang.annotation.ElementType;import java.lang.annotation.Retention;import java.lang.annotation.RetentionPolicy;import java.lang.annotation.Target;@Target({ElementType.METHOD, ElementType.TYPE})@Retention(RetentionPolicy.RUNTIME)public @interfacePassToken{booleanrequired()defaulttrue;}
RedisPreEnum枚举类:
/**
* redis key前缀
*/@AllArgsConstructor@Getterpublicenum RedisPreEnum {JWT_TOKEN_PRE("JWT_TOKEN_","token前缀",60*60*24);private String pre;private String desc;private Integer expired;}
User实体类:
@Data@EqualsAndHashCode(callSuper =false)@Accessors(chain =true)publicclassUsersimplementsSerializable{privatestaticfinallong serialVersionUID =1L;@TableId(value ="id", type = IdType.AUTO)private Integer id;private String name;private String password;}
这里使用的是mybatis-plus操作数据库,实体类使用代码生成器生成,没有使用mybatis-plus的小伙伴 只需要把findByUsername替换成自己的的即可,就是单纯 根据用户名查询用户信息
最重要的一个拦截器类:AuthenticationInterceptor
publicclassAuthenticationInterceptorimplementsHandlerInterceptor{@Resourceprivate RedisUtil redisUtil;@OverridepublicbooleanpreHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object)throws Exception {
String token = httpServletRequest.getHeader("token");// 从 http 请求头中取出 token// 如果不是映射到方法直接通过if(!(object instanceofHandlerMethod)){returntrue;}
HandlerMethod handlerMethod =(HandlerMethod) object;
Method method = handlerMethod.getMethod();//检查是否有passtoken注释,有则跳过认证if(method.isAnnotationPresent(PassToken.class)){
PassToken passToken = method.getAnnotation(PassToken.class);if(passToken.required()){returntrue;}}if(token == null){thrownewRuntimeException("无token,请重新登录");}
String userId;try{
userId = JWT.decode(token).getAudience().get(0);}catch(JWTDecodeException j){thrownewException("token无效");}
Users user = null;
String key = RedisPreEnum.JWT_TOKEN_PRE.getPre()+ userId + IpUtils.getIpAddr(httpServletRequest);if(redisUtil.hasKey(key)){
Object o = redisUtil.get(key);
user = JSONObject.toJavaObject((JSON) JSON.toJSON(o), Users.class);}if(user == null){thrownewRuntimeException("请重新登录");}// 验证 token
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(user.getPassword())).build();try{
jwtVerifier.verify(token);}catch(Exception e){thrownewException("token无效");}returntrue;}@OverridepublicvoidpostHandle(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
Object o, ModelAndView modelAndView)throws Exception {}@OverridepublicvoidafterCompletion(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
Object o, Exception e)throws Exception {}}
数据库mysql对应的表
效果截图:
使用postman,进行登录:
redis当中,可以看到有这个key
请求头当中携带登录接口返回的token
到这里,只是验证了单点登录。
接下来是验证同一个账号只能在一台设备上登录,由于本地测试,获取到的ip地址都是127.0.0.1。
这里使用postman在请求头添加x-forwarded-for字段,模拟ip地址
用一个账号,模拟用不同ip地址登录,看看在redis当中是什么样的
第一次登录:
redis当中的信息
第二次登录:
redis当中,可以看到redis当中key已经被覆盖了
当第一次登录的设备再次刷新页面就会,退出登录
总结:
本文只提供后端具体代码
同一个账号只能在一台设备上登录实现方式,
登录时,判断该账号是否在其它设备登录,如果有,就把key清除,然后存储用户信息和ip地址拼接为key,存储在redis当中
在拦截器当中(用户每次接口请求都会经过该拦截器),去获取这个key,如果key没有,直接返回重新登录。本文采取的并非建立长连接的方式。
所以同一个账号设备A登录,然后又在设备B登录时,设备A并不会直接强制退出,需要刷新页面才能强制退出,当然,如果你想达到强制退出的效果,可以模仿长连接的心跳检查,也就是前端定时向服务器发送接口请求,该接口什么事也不用做,只是接受浏览器的请求,然后经过拦截器即可。也能达到强制退出的效果。
好的,本文分享就到这里了,代码本人实测有效,有不好的地方或者错误的地方,欢迎各位多多评论指出。谢谢
版权归原作者 木偶亽~ 所有, 如有侵权,请联系我们删除。