文章目录
SSO简介
web系统由单系统发展成多系统组成的应用群,复杂性应该由系统内部承担,而不是用户。无论web系统内部多么复杂,对用户而言,都是一个统一的整体,也就是说,用户访问web系统的整个应用群与访问单个系统一样,登录/注销只要一次就够了。
单系统登录解决方案的核心是cookie,cookie携带会话id在浏览器与服务器之间维护会话状态。但cookie是有限制的,这个限制就是cookie的域(通常对应网站的域名),浏览器发送http请求时会自动携带与该域匹配的cookie,而不是所有cookie。
单点登录涉及sso认证中心与众子系统,子系统与sso认证中心需要通信以交换令牌、校验令牌及发起注销请求,因而子系统必须集成sso的客户端,sso认证中心则是sso服务端,整个单点登录过程实质是sso客户端与服务端通信的过程。sso认证中心与sso客户端通信方式有多种,httpClient,web service、rpc、restful api都可以。
什么是单点登录
什么是单点登录?单点登录全称Single Sign On(以下简称SSO),是指在多系统应用群中登录一个系统,便可在其他所有系统中得到授权而无需再次登录,包括单点登录与单点注销两部分。
登录
sso需要一个独立的认证中心,只有认证中心能接受用户的用户名密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权。间接授权通过令牌实现,sso认证中心验证用户的用户名密码没问题,创建授权令牌,在接下来的跳转过程中,授权令牌作为参数发送给各个子系统,子系统拿到令牌,即得到了授权,可以借此创建局部会话,局部会话登录方式与单系统的登录方式相同。这个过程,也就是单点登录的原理,用下图说明
下面对上图简要描述
- 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
- sso认证中心发现用户未登录,将用户引导至登录页面
- 用户输入用户名密码提交登录申请
- sso认证中心校验用户信息,创建用户与sso认证中心之间的会话,称为全局会话,同时创建授权令牌
- sso认证中心带着令牌跳转会最初的请求地址(系统1)
- 系统1拿到令牌,去sso认证中心校验令牌是否有效
- sso认证中心校验令牌,返回有效,注册系统1
- 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
- 用户访问系统2的受保护资源
- 系统2发现用户未登录,跳转至sso认证中心,并将自己的地址作为参数
- sso认证中心发现用户已登录,跳转回系统2的地址,并附上令牌
- 系统2拿到令牌,去sso认证中心校验令牌是否有效
- sso认证中心校验令牌,返回有效,注册系统2
- 系统2使用该令牌创建与用户的局部会话,返回受保护资源
用户登录成功之后,会与sso认证中心及各个子系统建立会话,用户与sso认证中心建立的会话称为全局会话,用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过sso认证中心,全局会话与局部会话有如下约束关系
- 局部会话存在,全局会话一定存在
- 全局会话存在,局部会话不一定存在
- 全局会话销毁,局部会话必须销毁
注销
单点登录自然也要单点注销,在一个子系统中注销,所有子系统的会话都将被销毁,用下面的图来说明
sso认证中心一直监听全局会话的状态,一旦全局会话销毁,监听器将通知所有注册系统执行注销操作
下面对上图简要说明
- 用户向系统1发起注销请求
- 系统1根据用户与系统1建立的会话id拿到令牌,向sso认证中心发起注销请求
- sso认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址
- sso认证中心向所有注册系统发起注销请求
- 各注册系统接收sso认证中心的注销请求,销毁局部会话
- sso认证中心引导用户至登录页面
单一服务器模式 登录
(1)使用session对象实现登录
登录成功之后,把用户数据放到session里面,session.setAttrinute(“user”,user)
判断是否登录,从session获取数据,可以获取登录信息,session.getAttrinute(“user”,user)
单点登录常见的三种方式
session广播机制实现
在一个模块中登录将用户信息写入到session,其他的模块进行一个复制
session过期时间:如果登录之后不做任何操作默认30分钟就失效了
缺点:如果只有几个模块,做个复制session没有什么问题,但是如果项目中有二十个模块,那我们的session就会复制二十次,而每次复制对于我们的资源都是一个极大的消耗。
cookie + redis实现
cookie是客户端技术存放在浏览器中,每次发送请求,都会带着cookie值进行发送。
redis基于key-value存储。
可以设置redis的过期时间做到跟session一样的效果
实现过程:
1、在项目中的任何一个模块进行登录,登录之后,把数据放到两个地方
redis:在key:生成唯一值(ip,用户id,UUID等等) 在value: 用户数据
cookie:把redis里面生成的key值放到cookie里面
2、访问项目中其他模块,发送请求带着cookie进行发送,获取到cookie值,拿着cookie做事情:
(1)把cookie获取值,到redis进行查询,根据key进行查询,如果查询到数据就是登录
token实现
token是什么?
按照一定规划生成字符串,字符串包括用户信息
实现过程:
1、在项目中的任何一个模块进行登录,登录之后,按照规则生成字符串,把登录必须用户包含到生成字符串里面,把字符串返回
(1)可以把字符串通过cookie返回
(2)把字符串通过地址栏返回
2、再去访问项目其他模块,每次访问在地址栏带着生成字符串,在访问模块里面获取地址栏字符串,根据字符串获取用户信息。可以获取到就是登录
token实现单点登录及注册Demo
点击跳转到码云仓库
技术桟
spring boot
mybatis-plus
jwt
swagger
pom
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.1</version>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--swagger-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.7.0</version>
</dependency>
<!--swagger ui-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>2.5</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
</dependencies>
application.properties
# 服务端口
server.port=8150
# 服务名
spring.application.name=ssodemo
# mysql数据库连接
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/alibaba?serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=root
#返回json的全局时间格式
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8
#配置mapper xml文件的路径
mybatis-plus.mapper-locations=classpath:com/alibaba/ssodemo/mapper/xml/*.xml
#mybatis日志
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
主程序
package com.alibaba.ssodemo;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
@SpringBootApplication
@ComponentScan(basePackages = {"com.alibaba"})
@MapperScan("com.alibaba.ssodemo.mapper")
public class SsodemoApplication {
public static void main(String[] args) {
SpringApplication.run(SsodemoApplication.class, args);
}
}
entity
package com.alibaba.ssodemo.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.Date;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class UcenterMember implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.ID_WORKER_STR)
private String id;
private String openid;
private String mobile;
private String password;
private String nickname;
private Integer sex;
private Integer age;
private String avatar;
private String sign;
private Boolean isDisabled;
private Boolean isDeleted;
@TableField(fill = FieldFill.INSERT)
private Date gmtCreate;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date gmtModified;
}
vo
package com.alibaba.ssodemo.entity.vo;
import lombok.Data;
@Data
public class RegisterVo {
private String nickname; //昵称
private String mobile; //手机号
private String password; //密码
}
自定为异常
package com.alibaba.ssodemo.exception;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor //有参构造方法
@NoArgsConstructor //无参构造方法
public class ServiceException extends RuntimeException {
private Integer code;//状态码
private String msg;//异常信息
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
}
controller
package com.alibaba.ssodemo.controller;
import com.alibaba.ssodemo.entity.UcenterMember;
import com.alibaba.ssodemo.entity.vo.RegisterVo;
import com.alibaba.ssodemo.service.UcenterMemberService;
import com.alibaba.ssodemo.utils.JwtUtils;
import com.alibaba.ssodemo.utils.R;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/ssodemo/")
public class UcenterMemberController {
@Autowired
private UcenterMemberService ucenterMemberService;
//登录
@PostMapping("/login")
public R loginUser(@RequestBody UcenterMember member){
//调用service方法实现登录,返回token值,使用jwt生成
String token = ucenterMemberService.login(member);
return R.ok().data("token",token);
}
//注册
@PostMapping("register")
public R register(@RequestBody RegisterVo registerVo){
ucenterMemberService.register(registerVo);
return R.ok();
}
//根据token获取用户的信息
@GetMapping("getMemberInfo")
public R getMemberInfo(HttpServletRequest response){
//调用工具类,根据response对象获取头信息,返回用户id
String idByJwtToken = JwtUtils.getMemberIdByJwtToken(response);
UcenterMember byId = ucenterMemberService.getById(idByJwtToken);
return R.ok().data("byId",byId);
}
}
mapper
package com.alibaba.ssodemo.mapper;
import com.alibaba.ssodemo.entity.UcenterMember;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface UcenterMemberMapper extends BaseMapper<UcenterMember> {
}
servuce
package com.alibaba.ssodemo.service;
import com.alibaba.ssodemo.entity.UcenterMember;
import com.alibaba.ssodemo.entity.vo.RegisterVo;
import com.baomidou.mybatisplus.extension.service.IService;
public interface UcenterMemberService extends IService<UcenterMember> {
//登录方法
String login(UcenterMember member);
//注册的方法
void register(RegisterVo registerVo);
}
serviceimpl
package com.alibaba.ssodemo.service.impl;
import com.alibaba.ssodemo.entity.UcenterMember;
import com.alibaba.ssodemo.entity.vo.RegisterVo;
import com.alibaba.ssodemo.exception.ServiceException;
import com.alibaba.ssodemo.mapper.UcenterMemberMapper;
import com.alibaba.ssodemo.service.UcenterMemberService;
import com.alibaba.ssodemo.utils.JwtUtils;
import com.alibaba.ssodemo.utils.MD5;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
@Service
public class UcenterMemberServiceImpl extends ServiceImpl<UcenterMemberMapper, UcenterMember> implements UcenterMemberService {
//登录的方法
@Override
public String login(UcenterMember member) {
String mobile = member.getMobile();//手机号
String password = member.getPassword();//密码
//对手机号及密码做非空判断
if (StringUtils.isEmpty(mobile) || StringUtils.isEmpty(password))
throw new ServiceException(20001,"手机号或密码不能为空");
//判断手机号是正确
QueryWrapper<UcenterMember> wrapper = new QueryWrapper<>();
wrapper.eq("mobile",mobile);
UcenterMember ucenterMember = baseMapper.selectOne(wrapper);
//判断查询对象是否为空
if (ucenterMember == null)
throw new ServiceException(20001,"输入的手机号错误");
//判断密码是否正确
if (!MD5.encrypt(password).equals(ucenterMember.getPassword()))
throw new ServiceException(20001,"输入的密码有误");
//登录成功,根据用户的id及昵称生成token
String jwtToken = JwtUtils.getJwtToken(ucenterMember.getId(), ucenterMember.getNickname());
return jwtToken;
}
//注册的方法
@Override
public void register(RegisterVo registerVo) {
//获取注册的数据
String mobile = registerVo.getMobile();//手机号
String nickname = registerVo.getNickname();//昵称
String password = registerVo.getPassword();//秘密
//对传过来的参数进行判断
if (StringUtils.isEmpty(mobile) || StringUtils.isEmpty(nickname) || StringUtils.isEmpty(password))
throw new ServiceException(20001,"注册失败,请填写相应的数据");
//对相同的手机号不进行添加
QueryWrapper<UcenterMember> wrapper = new QueryWrapper<>();
wrapper.eq("mobile",mobile);
Integer integer = baseMapper.selectCount(wrapper);
if (integer > 0 )
throw new ServiceException(20001,"手机号已经注册过了");
//将数据加入数据库中
UcenterMember ucenterMember = new UcenterMember();
ucenterMember.setNickname(nickname);
ucenterMember.setMobile(mobile);
ucenterMember.setPassword(MD5.encrypt(password));
ucenterMember.setIsDisabled(false);//用户不禁用
baseMapper.insert(ucenterMember);
}
}
utils
Jwt生成令牌
package com.alibaba.ssodemo.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.commons.lang.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
/**
* Jwt生成令牌
*/
public class JwtUtils {
//常量
public static final long EXPIRE = 1000 * 60 * 60 * 24; //tokent过期时间
public static final String APP_SECRET = "ukc8BDbRigUDaY6pZFfWus2jZWLPHO"; //秘钥
//生成token字符串的方法
public static String getJwtToken(String id, String nickname){
String JwtToken = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setHeaderParam("alg", "HS256")
.setSubject("guli-user")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE))
.claim("id", id)
.claim("nickname", nickname)
.signWith(SignatureAlgorithm.HS256, APP_SECRET)
.compact();
return JwtToken;
}
/**
* 判断token是否存在与有效
* @param jwtToken
* @return
*/
public static boolean checkToken(String jwtToken) {
if(StringUtils.isEmpty(jwtToken)) return false;
try {
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 判断token是否存在与有效
* @param request
* @return
*/
public static boolean checkToken(HttpServletRequest request) {
try {
String jwtToken = request.getHeader("token");
if(StringUtils.isEmpty(jwtToken)) return false;
Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
} catch (Exception e) {
e.printStackTrace();
return false;
}
return true;
}
/**
* 根据token获取会员id
* @param request
* @return
*/
public static String getMemberIdByJwtToken(HttpServletRequest request) {
String jwtToken = request.getHeader("token");
if(StringUtils.isEmpty(jwtToken)) return "";
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
Claims claims = claimsJws.getBody();
return (String)claims.get("id");
}
}
MD5加密
package com.alibaba.ssodemo.utils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* MD5加密方法
*/
public final class MD5 {
public static String encrypt(String strSrc) {
try {
char hexChars[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8',
'9', 'a', 'b', 'c', 'd', 'e', 'f' };
byte[] bytes = strSrc.getBytes();
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(bytes);
bytes = md.digest();
int j = bytes.length;
char[] chars = new char[j * 2];
int k = 0;
for (int i = 0; i < bytes.length; i++) {
byte b = bytes[i];
chars[k++] = hexChars[b >>> 4 & 0xf];
chars[k++] = hexChars[b & 0xf];
}
return new String(chars);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
throw new RuntimeException("MD5加密出错!!+" + e);
}
}
public static void main(String[] args) {
System.out.println("123456");
}
}
统一返回值
package com.alibaba.ssodemo.utils;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
/**
* 统一返回值
*/
@Data
public class R {
private Boolean succes;
private Integer code;
private String message;
private Map<String,Object> data = new HashMap<String,Object>();
//构造方法私有
private R(){}
//成功方法
public static R ok(){
R r = new R();
r.setSucces(true);
r.setCode(ResultCode.SUCCESS);
r.setMessage("成功");
return r;
}
//失败方法
public static R error(){
R r = new R();
r.setSucces(false);
r.setCode(ResultCode.ERROR);
r.setMessage("失败");
return r;
}
public Boolean getSucces() {
return succes;
}
public void setSucces(Boolean succes) {
this.succes = succes;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public Map<String, Object> getData() {
return data;
}
public void setData(Map<String, Object> data) {
this.data = data;
}
public R success(Boolean success){
this.setSucces(success);
return this;
}
public R message(String message){
this.setMessage(message);
return this;
}
public R code(Integer code){
this.setCode(code);
return this;
}
public R data(String key, Object value){
this.data.put(key, value);
return this;
}
public R data(Map<String, Object> map){
this.setData(map);
return this;
}
}
返回值状态码
package com.alibaba.ssodemo.utils;
/**
* 返回值状态码
*/
public interface ResultCode {
public static Integer SUCCESS = 20000; //成功
public static Integer ERROR = 20001; //失败
}
swagger
package com.alibaba.ssodemo.config;
import com.google.common.base.Predicates;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
@Configuration//配置类
@EnableSwagger2//swagger配置
public class SwaggerConfig {
@Bean
public Docket webApiConfig(){
return new Docket(DocumentationType.SWAGGER_2)
.groupName("webApi")
.apiInfo(webApiInfo())
.select()
//.paths(Predicates.not(PathSelectors.regex("/admin/.*")))
.paths(Predicates.not(PathSelectors.regex("/error.*")))
.build();
}
private ApiInfo webApiInfo(){
return new ApiInfoBuilder()
.title("网站-API文档")
.description("本文档描述了单点登录接口定义")
.version("1.0")
.contact(new Contact("Helen", "http://bing.com", "hq1162759071@foxmail.com"))
.build();
}
}