基于JWT的SSO单点登录
一、什么是单点登录?
单点登录(Single Sign On 简称 SSO)是一种统一认证和授权机制,指在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统,不再需要重新登录验证。(例如在网页上登录了百度账号,访问百度文库、百度网盘都已经是登录状态了,不需要每个应用都登录一遍)
二、单点登录机制
如上图,当用户第一次访问应用系统A时,因为还没有登录,会被跳转到SSO系统进行登录;SSO系统根据用户提供的登录信息进行身份校验,如果通过,则返回给用户一个认证凭据(Token)。用户访问其他应用系统时,将会带上此Token作为自己认证的凭据,应用系统B接受到请求之后会把Token送到认证系统进行校验,检查Token的合法性。如果通过,则成功访问应用系统B。
三、单点登录解决方案
先说一下普通登录实现方式
3.1 Token机制
Token就是一串加密(使用MD5,等不可逆加密算法)的字符串。具体流程如下:
1.客户端使用用户名跟密码请求登录,
2.服务端收到请求,验证用户名与密码,
3.验证成功后,服务端会签发一个加密的字符串(Token)保存到(Session,Redis,Mysql)中,并把这个Token发送给客户端,
4.客户端收到Token后存储在本地,如:Cookie 或 Local Storage 中,
5.客户端每次向服务端请求资源的时候需要带着服务端签发的 Token,
6.服务端收到请求,验证客户端请求中携带Token和服务器中保存的Token进行对比效验, 如果验证成功,就向客户端返回请求的数据。
3.2 JWT机制
JWT(JSON Web Token的缩写)它将用户信息加密到token里,服务器不保存任何用户信息。服务器通过使用保存的密钥验证JWTToken的正确性,只要正确即通过验证。
Token和JWT的区别:其实Token和JWT确实比较类似,只不过,Token需要查库验证token 是否有效,而JWT不用查库,直接在服务端进行校验,因为用户的信息及加密信息,和过期时间,都在JWT里,只要在服务端进行校验就行,并且校验也是JWT自己实现的。
了解了普通登录的方式,单点登录其实就是将登录、生成Token、校验Token的功能统一集成到单点登录系统中,这样就能多个系统共用一套Token。
四、基于JWT的单点登录
下面我们演示SpringBoot + JWT实现单点登录
Github地址: SteinsGate1097302/sso_demo
4.1 项目结构
项目结构包含三个模块,sso-center(认证中心),web-a、web-b(两个独立的系统)
4.2 创建认证系统模块
4.2.1 创建module,引入相关依赖
<dependencies>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.16</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
4.2.2 创建JWT工具类
/**
* JWT工具类(hutool)
*/
public class JwtUtil {
/**
* Description: 生成一个jwt字符串
*
* @param data jwt传输的数据
* @param secretKey 秘钥
* @param timeOut 超时时间(单位s)
* @return java.lang.String
*/
public static String encode(String data, String secretKey, int timeOut) {
DateTime now = DateTime.now();
DateTime newTime = now.offsetNew(DateField.SECOND, timeOut);
Map<String,Object> payload = new HashMap<>();
//签发时间
payload.put(JWTPayload.ISSUED_AT, now);
//生效时间
payload.put(JWTPayload.NOT_BEFORE, now);
//过期时间
payload.put(JWTPayload.EXPIRES_AT, newTime);
//载荷
payload.put("data", data);
return JWTUtil.createToken(payload, secretKey.getBytes());
}
/**
* Description: 解密jwt
*
* @param token token
* @param secretKey 秘钥
* @return cn.hutool.json.JSONObject
*/
public static JSONObject decode(String token, String secretKey) throws Exception {
if (token == null || token.length() == 0) {
throw new Exception("token为空...");
}
JWT jwt = JWTUtil.parseToken(token);
boolean verifyKey = jwt.setKey(secretKey.getBytes()).verify();
boolean verifyTime = jwt.validate(0);
// 秘钥验证和过期时间验证
if (! (verifyKey && verifyTime)){
throw new Exception("签名验证失败...");
}
return jwt.getPayloads();
}
}
4.2.3 创建SSO的控制器,实现登录、验证JWT接口
@Controller
@RequestMapping("/sso")
@Slf4j
public class LoginController {
@GetMapping("/login")
public String login(){
return "login";
}
@PostMapping("/login")
@ResponseBody
public String login(@RequestBody LoginRequest loginRequest,
@RequestParam("redirect") String redirectUrl){
// 在这里执行你的登录逻辑
if (! ("admin".equals(loginRequest.getUsername()) && "admin".equals(loginRequest.getPassword()))){
return "login failure...";
}
// URL返回给前端做重定向
String token = JwtUtil.encode("{'name': 'mike'}", "secretKey", 3600);
redirectUrl = redirectUrl + "?token=" + token;
return redirectUrl;
}
@GetMapping("/checkJwt")
@ResponseBody
public JSONObject checkJwt(@RequestParam("token") String token){
try {
return JwtUtil.decode(token, "secretKey");
}catch (Exception e){
log.error(String.valueOf(e));
return null;
}
}
// 内部类用于接收登录请求的数据
@Data
static class LoginRequest {
private String username;
private String password;
}
}
4.3 创建应用系统模块
4.3.1 创建module,引入依赖
<dependencies>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.16</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
4.3.2 编写拦截器,在收到请求时验证Token合法性
@Component
@WebFilter(urlPatterns = "/**", filterName = "loginFilter")
public class LoginFilter implements Filter {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final String serverHost = "http://localhost:8080";
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
// 根据前端传输方式修改 拿token的方式
String token = request.getParameter("token");
logger.info("token: " + token);
if (this.check(token)) {
// 验证通过,放行
filterChain.doFilter(servletRequest, servletResponse);
} else {
// 验证不通过,跳转回SSO登录页
HttpServletResponse response = (HttpServletResponse) servletResponse;
String redirect = serverHost + "/sso/login?redirect=" + request.getRequestURL();
response.sendRedirect(redirect);
}
}
private boolean check(String jwt) {
try {
if (jwt == null || jwt.trim().length() == 0) {
return false;
}
String object = HttpUtil.get(serverHost + "/sso/checkJwt?token=" + jwt);
return ! StringUtils.isEmpty(object);
} catch (Exception e) {
logger.error("向认证中心请求失败", e);
return false;
}
}
}
4.3.3 编写应用系统控制器
@RestController
@RequestMapping("/demo")
public class DemoController {
@GetMapping("/hello")
public String hello(@RequestParam("token") String token){
return "WebA \n\n token:" + token;
}
}
4.4 功能测试
4.4.1 访问应用系统A(http://localhost:8081/demo/hello)
因为没有登录,跳转到SSO登录页面,并且附带原URL地址
4.4.2 登录后自动跳转回系统A
跳转回系统A后,前端将Token保存到本地
4.4.3 使用此Token访问系统B,发现可以成功访问
操作4.4.2与4.4.3一般会将Token放在请求头中传输,此处方便起见直接拼在URL后