功能简述: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)
public class RedisConfig {
@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<Object, Object> redisTemplate(
RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
//使用fastjson序列化
FastJsonRedisSerializer fastJsonRedisSerializer = new FastJsonRedisSerializer(Object.class);
// value值的序列化采用fastJsonRedisSerializer
template.setValueSerializer(fastJsonRedisSerializer);
template.setHashValueSerializer(fastJsonRedisSerializer);
// key的序列化采用StringRedisSerializer
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setConnectionFactory(redisConnectionFactory);
return template;
}
@Bean
@ConditionalOnMissingBean(StringRedisTemplate.class)
public StringRedisTemplate stringRedisTemplate(
RedisConnectionFactory redisConnectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
}
全局拦截器:InterceptorConfig
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authenticationInterceptor())
.addPathPatterns("/**");
}
@Bean
public AuthenticationInterceptor authenticationInterceptor() {
return new AuthenticationInterceptor();
}
}
UserController:
@RestController
@RequestMapping("/users")
public class UsersController {
@Resource
private UsersService usersService;
@Resource
TokenService tokenService;
@Resource
private RedisUtil redisUtil;
//登录
@PassToken
@PostMapping("/login")
public Object login(@RequestBody Users user, HttpServletRequest request){
JSONObject jsonObject=new JSONObject();
//根据用户名查询用户信息
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 {
//请空之前的key
for (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:
@Component
public class TokenService {
private final static 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;
public class IpUtils {
//客户端类型 手机、电脑、平板
//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();
public static 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为客户ip
if (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 @interface PassToken {
boolean required() default true;
}
RedisPreEnum枚举类:
/**
* redis key前缀
*/
@AllArgsConstructor
@Getter
public enum 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)
public class Users implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private String name;
private String password;
}
这里使用的是mybatis-plus操作数据库,实体类使用代码生成器生成,没有使用mybatis-plus的小伙伴 只需要把findByUsername替换成自己的的即可,就是单纯 根据用户名查询用户信息
最重要的一个拦截器类:AuthenticationInterceptor
public class AuthenticationInterceptor implements HandlerInterceptor {
@Resource
private RedisUtil redisUtil;
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception {
String token = httpServletRequest.getHeader("token");// 从 http 请求头中取出 token
// 如果不是映射到方法直接通过
if (!(object instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) object;
Method method = handlerMethod.getMethod();
//检查是否有passtoken注释,有则跳过认证
if (method.isAnnotationPresent(PassToken.class)) {
PassToken passToken = method.getAnnotation(PassToken.class);
if (passToken.required()) {
return true;
}
}
if (token == null) {
throw new RuntimeException("无token,请重新登录");
}
String userId;
try {
userId = JWT.decode(token).getAudience().get(0);
} catch (JWTDecodeException j) {
throw new Exception("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) {
throw new RuntimeException("请重新登录");
}
// 验证 token
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(user.getPassword())).build();
try {
jwtVerifier.verify(token);
} catch (Exception e) {
throw new Exception("token无效");
}
return true;
}
@Override
public void postHandle(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
Object o, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(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并不会直接强制退出,需要刷新页面才能强制退出,当然,如果你想达到强制退出的效果,可以模仿长连接的心跳检查,也就是前端定时向服务器发送接口请求,该接口什么事也不用做,只是接受浏览器的请求,然后经过拦截器即可。也能达到强制退出的效果。
好的,本文分享就到这里了,代码本人实测有效,有不好的地方或者错误的地方,欢迎各位多多评论指出。谢谢