目录
一、理论
1.SSO
2.JWT
#.组成
#.如何工作
3.Redis RSA MD5
4.AOP
二、实现过程
#.准备工作
#.登录
#.测试类
#.插拔式注解
#.测试
最近有机会接触到了单点登录,写一篇文章记录一下整个实现的流程。
技术名词
[图片来源于网络,版权归原作者所有,如有侵权,请告知立即删除]
举一些例子:登录了淘宝,进入之后点击天猫不用重复登录。最常用的百度,只要登陆了,进入其他应用如百度文库、贴吧等都是不用重复登录的。
总结:一处登录,处处登录,一处注销,处处注销。
再详细点:客户端请求需要携带TOKEN(内有用户信息等)才能访问一些登录后才能访问的URL,如果不传递TOKEN或者是过期以及被篡改都是不能通过校验的,就会让用户重新登录,登录成功之后服务器会返回TOKEN给客户端,客户端只要携带这个TOKEN就可以访问其他分布式的系统(这些系统都可以识别TOKEN),如果注销,TOKEN被删除,则会要求用户重新登录。
格式:xxx.yyy.zzz
在认证的时候,用户通过凭证成功登陆之后,服务器会返回一个一个jwt(TOKEN)给前端,在此之后,它便是用户的凭证了,在每一次的访问上,可以在请求的Header上携带这个凭证,在服务器上受保护的路由会验证此TOKEN。别忘了一点,jwt是可以携带信息的,如用户的账户类型,通过此也可以做权限管理,如果jwt携带足够多的数据,可以减少不必要的数据库访问。
Redis是什么就不介绍了,主要说一下我在实现的过程中充当的作用。
面向切面编程,通过阅读代码能够获得更好的理解。
创建一个授权中心(一个普通的SpringBoot项目即可)
按照正常的创建流程创建好之后,创建LoginController用作入口。
#带有"#"符号的需要手动填写
spring:
redis:
database: 0
# host: 000.000.000.000
# port: 6379
connect-timeout: 5000
jedis:
pool:
max-active: 8
max-idle: 8
min-idle: 0
#保持所有配置文件相同即可
jwt:
secret: adsfdsfsdfdsfwetrwgfsdfsdfwsEFSEAFESF
# 有效期,单位秒
expire-time-in-second: 10000
package com.shixin.security.controller;
/**
* @Description
* @Author shixin
* @Date 2021/4/23 23:38
*/
@Slf4j
@RestController
@RequestMapping("/login")
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class LoginController {
private final PcUserMapper pcUserMapper;
private final JedisPool jedisPool;
private final JwtOperator jwtOperator;
private static String RSA_PUBLIC_KEY = "RSA_PUBLIC_KEY";
private static String RSA_PRIVATE_KEY = "RSA_PRIVATE_KEY";
@Value("${jwt.expire-time-in-second}")
private Integer EXPIRATION_TIME_IN_SECOND;
/**
* @Description: 创建RSA秘钥对 返回公钥给前端 redis保存密钥对,主要作用为不断更改用户使用
* 相同数据加密后的结果,使得数据难以伪造
* @Date: 2021/4/24 11:52
*/
@PostMapping("/refreshKey")
public Result getRSA() throws NoSuchAlgorithmException {
Jedis jedis = jedisPool.getResource();
Map keyMap = RSAUtil.genKeyPair(RSA_PUBLIC_KEY,RSA_PRIVATE_KEY);
String publicKey = keyMap.get(RSA_PUBLIC_KEY);
String privateKey = keyMap.get(RSA_PRIVATE_KEY);
jedis.set(RSA_PUBLIC_KEY,publicKey);
jedis.set(RSA_PRIVATE_KEY, privateKey);
return Result.success("请求成功",publicKey);
}
/**
* @Description: 传递公钥加密过的密码,后端解密后用MD5J加密和数据库面对比
* @Date: 2021/4/24 11:48
*/
@PostMapping("/check")
public Result login(@RequestBody PcUser param) throws Exception {
Jedis jedis = jedisPool.getResource();
//校验空值
if (!StringUtils.isBlank(param.getUserId()) && !StringUtils.isBlank(param.getPassword())){
//获取信息,没有连接数据库可以自行伪造数据
PcUser pcUser = pcUserMapper.selectByUserId(param.getUserId());
//通过redis获取的私钥对前端传过来的用公钥加密过的密码进行解密
String password = RSAUtil.decrypt(param.getPassword(), jedis.get(RSA_PRIVATE_KEY));
//通过getSaltverifyMD5这个方法可以校验数据库密文的明文是否是password,如果是,就可以开始颁发TOKEN了
if (pcUser != null && MD5Util.getSaltverifyMD5(password,pcUser.getPassword())){
//构件jwt第二部分的public部分,如果不知道什么意思的可以看理论-JWT部分的介绍
Map userInfo = new HashMap<>(5);
userInfo.put("id",pcUser.getId());
userInfo.put("type",pcUser.getType());
userInfo.put("userName",pcUser.getUserName());
userInfo.put("email",pcUser.getEmail() == null ? "":pcUser.getEmail());
userInfo.put("avatar",pcUser.getAvatar() == null ? "":pcUser.getAvatar());
//生成TOKEN
String token = jwtOperator.generateToken(userInfo);
//将TOKEN存到Redis里面,格式:TOKEN_[userId] : TOKEN
jedis.setex(ParamNameEnum.TOKEN_ +pcUser.getUserId(),EXPIRATION_TIME_IN_SECOND,token);
//构建返回给前端的数据,在此之后,前端的请求都会携带token访问,只要token验证通过,便会放行
return Result.success(LoginDTO.builder()
.token(token)
.userInfo(LoginDTO.UserInfo.builder()
.id(pcUser.getId())
.type(pcUser.getType())
.userId(pcUser.getUserId())
.userName(pcUser.getUserName())
.avatar(pcUser.getAvatar() == null ? "" : pcUser.getAvatar())
.email(pcUser.getEmail() == null ? "" : param.getEmail())
.build())
.build()
);
}else {
return Result.fail("账号或密码错误");
}
}else {
return Result.fail("用户或密码为空");
}
}
}
package com.shixin.pawcode.admin.controller;
/**
* @Description
* @Author shixin
* @Date 2021/4/24 22:15
*/
@Slf4j
@RestController
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class TestController {
private final JedisPool jedisPool;
@Value("${jwt.expire-time-in-second}")
private Integer EXPIRATION_TIME_IN_SECOND;
@GetMapping("/checkAspect")
//这个注解的编写后面会写到,这就是上面提到过的AOP编程,进入此方法的请求都会被拦截
@CheckLogin
public String checkAspect(HttpServletRequest request, HttpServletResponse response){
//处理业务逻辑
//获取经过注解处理之后在request添加的TOKEN,怎么处理,下面有介绍
String token = (String) request.getAttribute(ParamNameEnum.L_TOKEN.name());
if (!StringUtils.isBlank(token)){
Jedis jedis = jedisPool.getResource();
//在业务逻辑处理完毕并且不出错的情况下才刷新TOKEN,否则还是使用原来的TOKEN
response.setHeader(ParamNameEnum.L_TOKEN.name(),token);
jedis.setex(ParamNameEnum.TOKEN_.name() + request.getAttribute("userId"),EXPIRATION_TIME_IN_SECOND,token);
}
return token;
}
}
CheckLogin.java
package com.shixin.common.auth;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckLogin {
}
CheckLoginAspect.java
package com.shixin.common.auth;
/**
* @Description 只要添加了CheckLogin注解的都会经过此方法
* @Author shixin
* @Date 2021/4/24 21:46
*/
@Aspect
@Component
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class CheckLoginAspect {
private final JwtOperator jwtOperator;
private final JedisPool jedisPool;
@Value("${jwt.expire-time-in-second}")
private Integer EXPIRATION_TIME_IN_SECOND;
//如果不了解此注解的作用可以自行百度关于AOP切面编程,注意里的参数要填写注解的包名全路径
@Around("@annotation(com.shixin.common.auth.CheckLogin)")
public Object checkLogin(ProceedingJoinPoint point) throws Throwable {
//1.从header里获取token
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes attributes = (ServletRequestAttributes) requestAttributes;
HttpServletRequest request = attributes.getRequest();
//这里要去redis校验 并且在业务完成后刷新过期时间 token会变
//在前端传输的过程中,我设定多传了一个ID,用于创建Redis的key和获取,可以直接通过id和token来确定是否登录
String token = request.getHeader(ParamNameEnum.L_TOKEN.name());
String userId = request.getHeader(ParamNameEnum.U_ID.name());
//校验token 是否和过期
Jedis jedis = jedisPool.getResource();
String redisToken = jedis.get(ParamNameEnum.TOKEN_.name()+ userId);
if (!StringUtils.isBlank(redisToken) || !StringUtils.isBlank(token)) {
if (!redisToken.equals(token) ) { // || !jwtOperator.validateToken(token) 这个是校验是否合法,redis里存的jwt本身就是合法的了,而且有效期也有,所以可以省略这个校验
//自定义异常类,自己随意创建
throw new SecurityException("Token不合法");
} else {
//添加用户信息到attribute
Claims claims = jwtOperator.getClaimsFromToken(token);
Map userInfo = new HashMap<>(5);
Object id = claims.get("id");
request.setAttribute("id", id);
userInfo.put("id",id);
Object type = claims.get("type");
request.setAttribute("type", type);
userInfo.put("type",type);
Object userName = claims.get("userName");
request.setAttribute("userName", userName);
userInfo.put("userName",userName);
Object email = claims.get("email") == null ? "" : claims.get("email");
request.setAttribute("email", email);
userInfo.put("email",email);
Object avatar = claims.get("avatar") == null ? "" : claims.get("avatar");
request.setAttribute("avatar", avatar);
userInfo.put("avatar",avatar);
//生成新的TOKEN
String newToken = jwtOperator.generateToken(userInfo);
//通过request将TOKEN传递给方法
request.setAttribute(ParamNameEnum.L_TOKEN.name(), newToken);
//执行被注解的方法
return point.proceed();
}
} else {
throw new SecurityException("用户未登录");
}
}
}
假定:本地测试,端口8083