什么是单点登录?

单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO

的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

假设一个企业有多个应用程序,比如OA系统、CRM系统、ERP系统等等,每个系统都有自己的用户账号和密码。如果用户要使用这些系统,需要在每个系统中单独输入账号密码进行身份验证。如果应用了单点登录技术,用户只需要在其中一个系统中验证身份,例如在OA系统中输入账号和密码登录,之后用户就可以无需再次输入账号和密码自动登录其他系统(如CRM系统、ERP系统等),因为这些系统都已经通过单点登录技术与OA系统建立了连接,可以自动获取OA系统的用户登录信息。

原理:

当用户第一次登录应用系统1的时候,会被引导到认证系统中进行登录,根据用户提供的登录信息进行校验,校验成功后认证系统会返回一个认证凭证ticket,并把这个ticket存到redis缓存中。当用户登录其他的系统的时候,只需要带着这个ticket,经过认证系统的校验,校验成功则无需再次登录即可访问其他。

致远OA nginx部署_致远OA nginx部署

其他登录方式

1、免密登录:登录成功之后,硬盘cookie中会存在相应的验证信息,即使浏览器关闭,再次打开浏览器的时候可以在一段时间内都不用输入密码和用户名即可登录。
实现方式:cookie
优点:简单
缺点:不安全
2、 同域登录:同域名下不同模块互相登录
实现方式:cookie,session
优点:相对简单,可以同域名下不同模块互相登录
缺点:不安全
代表技术:Spring session
3、单点登录:
实现方式:cookie,redis
优点:可以在不同域名下互相登录
缺点:管理麻烦,部署复杂,代码编写量提升。可能不安全
代表技术:shiro,Spring Security,cas,OAuth2.。。。
在第一次登录成功的之后利用cookie来存储登录信息,以便在去其他系统的时候直接从request域中获取cookie中的用户信息,从而login(admin),validate(ticket),去认证系统认证。

要实现SSO,需要以下主要的功能:

redis的数据是(key-value)->(ticket,admin)
1、所有应用系统共享一个身份认证系统。(抽取出来一个系统)
2、登录成功,返回ticket。
根据前台传过来的用户信息获取用户名和密码。先判断用户名和密码是否为空,是否存在,是否匹配,如果成功,则创建唯一数据ticket数据,并缓存到redis中设置过期时间,返回一个ticket,便于用户拿到ticket,在后面进入其他系统的时候来用ticket进入认证系统。
3、用户在其他系统通过票据来进行认证,返回用户。
用户拿着票据,要先进行校验,判断ticket是否为空,是否过期,过期就是获取key值的时候返回的value值为空,所以我们只需要判断value值是否为空即可,如果以上情况都不存在,那么就说明ticket,admin是存在的,返回用户。

为什么不把sso系统放在rpc系统下呢?

有个疑问就是sso系统也是使用了dubbo协议,有rpc协议框架的影子,那为什么不把他们放在一块呢?

因为Shop-RPC 和 Shop-SSO 是两个独立的系统,因为它们分别提供不同的服务。

Shop-RPC 是一个远程过程调用框架,主要用于分布式系统中不同节点之间的通信。它将不同节点之间的通信封装成了一种类似于本地方法调用的方式,使得远程调用变得像本地调用一样简单。Shop-RPC 的主要作用是提供高效的远程服务调用,为服务之间的通信提供便利。

Shop-SSO 是一个单点登录系统,主要用于用户认证和授权。它能够提供用户中心、权限管理、单点登录等功能,为多个系统之间的用户认证提供了便利。Shop-SSO 的主要作用是提供安全的用户认证和授权,为不同系统之间的用户认证提供统一的解决方案。

通过将 Shop-RPC 和 Shop-SSO 进行分离可以使它们各自专注于自己的领域,提高系统的可维护性和可扩展性。同时,它们之间可以通过 Shop-RPC 提供的远程调用功能进行通信,保证了系统之间的协作和数据的传递。

代码实现

新建sso-model,导入依赖,写yml文件,加@enableDubbo注解

同rpc一样的依赖,yml文件也类似,但是这是一个新的系统了,因此dubbo端口号需要更改。

利用Mybatis-Plus 生成pojo类,编写service,serviceImpl

疑问?如果前端判断了用户名是否为空,如果为空就不发送请求,后端还有必要判断用户名是否为空嘛

后端在处理用户请求时仍然需要对用户名是否为空做必要的校验。这是因为前端校验只是一种辅助手段,用户可以通过浏览器控制台等方式绕过前端校验,直接发送带有空用户名的请求到后端

因此,为了保证应用的安全性和正确性,后端需要对所有的输入参数进行严格的校验和过滤。对于前端已经校验过的参数,后端可以简单地进行再次校验,以避免非法参数对系统造成的潜在危害。在实际开发中,我们通常会采用多层校验的方式,既在前端进行校验,又在后端进行校验,以确保输入参数的正确性和安全性。

ServiceImpl

@Service(interfaceClass = ShopSSOService.class)
public class ShopSSOServiceImpl implements ShopSSOService {
    @Resource
    AdminMapper adminMapper;
    @Resource
    RedisTemplate<String,String> redisTemplate;
    @Value("${user.ticket}")
    String userticket;

    /**
     * 登录成功,返回ticket
     * @param admin
     * @return
     */
    @Override
    public String login(Admin admin) {
        String ticket = "";
        //即使前端有所校验,但是后端仍然有必要再次进行校验,你永远不知道黑客的操作。
        if(StringUtils.isEmpty(admin.getPassword().trim())||StringUtils.isEmpty(admin.getUserName().trim())){
            System.out.println("用户名或密码为空");
            return null;
        }
        AdminExample adminExample = new AdminExample();
        adminExample.createCriteria().andUserNameEqualTo(admin.getUserName());
        List<Admin> admins = adminMapper.selectByExample(adminExample);
        //得到的list集合是否为空,用户名是否唯一
        if(CollectionUtils.isEmpty(admins) ||1!=admins.size()){
            System.out.println("获取admins失败");
            return null;
        }
       Admin adminJdbc = admins.get(0);

        //用户名验证成功,接下来验证密码,因为存入数据库的密码是经过MD5加密的,所以我们要将用户传过来的明文数据进行MD5加密,再与数据库中进行比对看是否一致。
        String adminPassword = Md5Util.getMd5WithSalt(admin.getPassword(),adminJdbc.getEcSalt());
        System.out.println(admin.getPassword()+":"+adminJdbc.getEcSalt());
        System.out.println(adminPassword);
        if(!adminPassword.equals(adminJdbc.getPassword())){
            System.out.println("密码错误");
            return null;
        }
        //验证通过,通过UUID获取ticket
        ticket = UUIDUtil.getUUID();
        //加入缓存中,并设置过期时间。
        ValueOperations<String,String> valueOperations = redisTemplate.opsForValue();

        valueOperations.set((userticket+":"+ticket), JsonUtil.object2JsonStr(adminJdbc),30, TimeUnit.MINUTES);
        return ticket;
    }

    /**
     * 其他系统验证ticket,返回用户信息
     * @param ticket
     * @return
     */
    @Override
    public Admin validate(String ticket) {
        //判断ticket是否为空
        if(StringUtils.isEmpty(ticket)){
            System.out.println("ticket为空");
            return null;
        }
        //获取ticket对应的admin
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        String s = valueOperations.get((userticket +":" +ticket));
        if(StringUtils.isEmpty(s)){
            System.out.println("ticket过期");
            return null;
        }
        //返回admin
        return JsonUtil.jsonStr2Object(s,Admin.class);
    }
}

后台管理系统

在后台管理系统实现单点登录

已知后台管理系统需要传过来三个参数,用户名,用户密码,验证码。
然后我们经过Controller拦截器,判断有没有登录,如果登录过了进入系统。如果没有登录进入登录页面。如果要进入其他系统,则实现单点登录。

导入sso依赖

<dependency>
            <groupId>com.wll</groupId>
            <artifactId>shop-sso</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

登录,编写Controller

在serviceImpl中传过来的三个参数,首先需要判断验证码是否一致,一致则调用login(admin)拿到ticket,来判断是否登录成功。如果ticket不是空的或者空字符串则登录成功,把ticket存到硬盘cookie中,便于用户访问其他的系统的时候拿到cookie中的value进行vilidate(ticket),因为页面需要反显出我们的用户信息,因此我们把用户信息存到session中。
因为session和cookie也会有异常。所以可以对他们进行包装。

@Controller
@RequestMapping("/user")
public class UserController {

	@Reference(interfaceClass = ShopSSOService.class)
	private ShopSSOService ssoService;
	@Autowired
	private CookieService cookieService;

	@RequestMapping("/login")
	@ResponseBody
	public BaseResult login(Admin admin, HttpServletRequest request, HttpServletResponse response, String verify){
		//获取验证码
		String capText = (String) request.getSession().getAttribute("pictureVerifyKey");
		BaseResult baseResult = new BaseResult();
		//判断验证码是否为空或者验证码是否一致
		if (StringUtils.isEmpty(verify.trim())||!verify.trim().equals(capText)){
			baseResult.setCode(BaseResultEnum.PASS_ERROR_03.getCode());
			baseResult.setMessage(BaseResultEnum.PASS_ERROR_03.getMessage());
			return baseResult;
		}
		String ticket = ssoService.login(admin);
		//登录失败
		if(!StringUtils.isEmpty(ticket)){
			//登录成功
			boolean result = cookieService.setCookie(request, response, ticket);
			request.getSession().setAttribute("user",admin);
			return result?BaseResult.success():BaseResult.error();
		}
		baseResult.setCode(BaseResultEnum.PASS_ERROR_04.getCode());
		baseResult.setMessage(BaseResultEnum.PASS_ERROR_04.getMessage());
		return baseResult;
	}

拦截器

这里每一个系统都有一个拦截器。
拦截器只需要两步

一、编写拦截器类
二、注册拦截器到springmvc中

一、编写拦截器类 实现HandlerIntercepetor接口

实现HandlerInterceptor接口,重写他的preHandler,postHandler,afterCompotation方法,
拦截器怎么拦截登录方法看登录的时候我们做了什么,登录的时候我们把ticket存到了cookie里面,在login(admin)函数里面把ticket存到了redis里面并设置了过期时间,把用户信息存到了session里面,因此我们可以通过判断cookie里面是否有ticket,如果有则进行验证,验证成功的话重新设置redis中的过期时间。如果没有则重定向到登录页面。

@Component
public class ManagerLoginIntercepter implements HandlerInterceptor {
    @Resource
    RedisTemplate<String,String> redisTemplate ;
    @Value("${user.ticket}")
    String userTicket;
    @Resource
    ShopSSOService ssoService;


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        if(!StringUtils.isEmpty(ticket)){
            //票据不为空,进行验证
            Admin admin = ssoService.validate(ticket);
            if(null != admin){
                //验证通过,重新设置过期时间,存入session域中
                ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
                request.getSession().setAttribute("user",admin);
                valueOperations.set(userTicket+":"+ticket, JsonUtil.object2JsonStr(admin));
                return true;
            }
        }
        response.sendRedirect(request.getContextPath()+"/login");
            return false;
    }

配置拦截器类到springMVC 实现WebMvcConfigurer接口

这是一个Spring MVC的配置类,@Configuration表示这是一个Java配置类,@EnableWebMvc表示启用Spring MVC框架的特性。该类实现了WebMvcConfigurer接口,用来配置Spring MVC的相关属性。

register.addInterceptor(配置器)
.addPathPattern(拦截路径)
.excludePathPatterns(不拦截的路径)
@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {

	@Resource
	private ManagerLoginIntercepter loginInterceptor;

	/**
	 * addInterceptor:添加自定义拦截器
	 * addPathPatterns:添加拦截请求  /**表示拦截所有
	 * excludePathPatterns:不拦截的请求
	 * @param registry
	 */
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(loginInterceptor)
				.addPathPatterns("/**")
				.excludePathPatterns("/static/**")
				.excludePathPatterns("/login/**")
				.excludePathPatterns("/image/**")
				.excludePathPatterns("/user/login/**")
				.excludePathPatterns("/user/logout/**");
	}

	/**
	 * 放行静态资源
	 * @param registry
	 */
	@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
	}
}

登出

在sso中写出登出,登出的时候需要将redis清空。
在调用的时候需要把cookie清空,session清空。何时创建,何时消亡,都是一对一的。

验证码 google Kaptcha

图像验证码显示功能使用 google Kaptcha 验证码产品 实现前台验证码显示功能
父项目中声明依赖,定义版本号
manager系统导入依赖

<dependency>
        <groupId>com.github.axet</groupId>
        <artifactId>kaptcha</artifactId>
      </dependency>

写配置类,定义验证码的格式,字体颜色等等

@Configuration
public class CaptchaConfig {

   @Bean
   public DefaultKaptcha getDefaultKaptcha(){
      //验证码生成器
      DefaultKaptcha defaultKaptcha=new DefaultKaptcha();
      //配置
      Properties properties = new Properties();
      //是否有边框
      properties.setProperty("kaptcha.border", "yes");
      //设置边框颜色
      properties.setProperty("kaptcha.border.color", "105,179,90");
      //边框粗细度,默认为1
      // properties.setProperty("kaptcha.border.thickness","1");
      //验证码
      properties.setProperty("kaptcha.session.key","code");
      //验证码文本字符颜色 默认为黑色
      properties.setProperty("kaptcha.textproducer.font.color", "blue");
      //设置字体样式
      properties.setProperty("kaptcha.textproducer.font.names", "宋体,楷体,微软雅黑");
      //字体大小,默认40
      properties.setProperty("kaptcha.textproducer.font.size", "40");
      //验证码文本字符内容范围 默认为abced2345678gfynmnpwx
      // properties.setProperty("kaptcha.textproducer.char.string", "");
      //字符长度,默认为5
      properties.setProperty("kaptcha.textproducer.char.length", "4");
      //字符间距 默认为2
      properties.setProperty("kaptcha.textproducer.char.space", "6");
      //验证码图片宽度 默认为200
      properties.setProperty("kaptcha.image.width", "200");
      //验证码图片高度 默认为40
      properties.setProperty("kaptcha.image.height", "50");
      Config config = new Config(properties);
      defaultKaptcha.setConfig(config);
      return defaultKaptcha;
   }
}

写生成验证码的代码
生成验证码
这段代码是一个Java Spring框架的ImageController控制器类,主要是实现生成图片验证码的功能。在getKaptchaImage方法中,通过使用第三方库DefaultKaptcha生成验证码的文本和图片,并将验证码文本放入session中保存。然后将生成的图片输出到HttpServletResponse的输出流中,返回给前端页面展示。在输出图片之前,还设置了一些响应头,这些响应头主要是为了防止浏览器缓存该图片,确保每次生成的验证码都是随机的。

前端的页面通过向后端发起请求获取验证码图片。比如在页面中有一个验证码图片的img标签,src属性指向的就是后端生成验证码图片的接口,即ImageController中的getKaptchaImage方法。

<img src="/image/getKaptchaImage" alt="验证码">

这样,当页面加载时,会向后端发送一个getKaptchaImage接口的请求,后端会返回生成的验证码图片,前端就可以显示该图片了。在后续用户输入验证码时,前端会将用户输入的验证码和后端保存在session中的验证码进行比对,以此来验证验证码是否正确。

@Controller
@RequestMapping("image")
public class ImageController {

	@Autowired
	private DefaultKaptcha defaultKaptcha;


	@RequestMapping("getKaptchaImage")
	public void getKaptchaImage(HttpServletRequest request, HttpServletResponse response) {
		// 定义response输出类型为image/jpeg类型
		response.setDateHeader("Expires", 0);
		// Set standard HTTP/1.1 no-cache headers.
		response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
		// Set IE extended HTTP/1.1 no-cache headers (use addHeader).
		response.addHeader("Cache-Control", "post-check=0, pre-check=0");
		// Set standard HTTP/1.0 no-cache header.
		response.setHeader("Pragma", "no-cache");
		// return a jpeg
		response.setContentType("image/jpeg");
		//---------------生成验证码----------------
		String text = defaultKaptcha.createText();
		System.out.println("验证码内容为:" + text);
		//将验证码放入session中
		request.getSession().setAttribute("pictureVerifyKey",text);
		BufferedImage image = defaultKaptcha.createImage(text);
		ServletOutputStream outputStream = null;
		try {
			outputStream = response.getOutputStream();
			ImageIO.write(image, "jpg", outputStream);
			outputStream.flush();
		} catch (IOException e) {
			e.printStackTrace();
		} finally {
			try {
				if (null != outputStream) {
					outputStream.close();
				}
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
	//---------------生成验证码----------------
}