单点登录系统开发

一、SSO(单点登录系统简介)

  • 基本介绍

单点登录SSO(Single Sign On)就是在一个多系统共存的环境下,用户在一处登录后,就不用在其他系统中登录,也就是用户的一次登录能得到其他所有系统的信任。单点登录在大型网站里使用得非常频繁,例如像阿里巴巴这样的网站,在网站的背后是成百上千的子系统,用户一次操作或交易可能涉及到几十个子系统的协作,如果每个子系统都需要用户认证,不仅用户会疯掉,各子系统也会为这种重复认证授权的逻辑搞疯掉。

  • 解决方案

只要解决了以上的问题,达到了开头讲得效果就可以说是SSO。最简单实现SSO的方法就是用Cookie,实现流程如下所示:
单点登录系统开发_第1张图片

以上的方案是把信任存储在客户端的Cookie里,这种方法虽然实现方便但立马会让人质疑两个问题:
1、Cookie不安全;
2、不能跨域免登。
对于第一个问题一般都是通过加密Cookie来处理,第二个问题是硬伤,其实这种方案的思路的就是要把这个信任关系存储在客户端,要实现这个也不一定只能用Cookie,用flash也能解决,flash的Shared Object API就提供了存储能力。

一般说来,大型系统会采取在服务端存储信任关系的做法,实现流程如下所示:
这里写图片描述

以上方案就是要把信任关系存储在单独的SSO系统里,说起来只是简单地从客户端移到了服务端,但其中几个问题需要重点解决:
1、如何高效存储大量临时性的信任数据;
2、如何防止信息传递过程被篡改;
3、如何让SSO系统信任登录系统和免登系统。
对于第一个问题,一般可以采用类似与Redis的分布式缓存的方案,既能提供可扩展数据量的机制,也能提供高效访问。对于第二个问题,一般采取数字签名的方法,要么通过数字证书签名,要么通过像md5的方式,这就需要SSO系统返回免登URL的时候对需验证的参数进行md5加密,并带上token一起返回,最后需免登的系统进行验证信任关系的时候,需把这个token传给SSO系统,SSO系统通过对token的验证就可以辨别信息是否被改过。对于最后一个问题,可以通过白名单来处理,说简单点只有在白名单上的系统才能请求生产信任关系,同理只有在白名单上的系统才能被免登录。

  • SSO与该项目的关系

之前实现的登录和注册是在同一个tomcat内部完成,不存在单点登录的问题。现在的系统架构每个系统都是单独部署运行一个单独的tomcat,所以,不能将用户的登录信息保存到session中(多个tomcat的session是不能高效共享的),所以需要一个单独的系统来维护用户的登录信息。

二、SSO系统框架的搭建

1、搭建Maven工程

  • pom.xml内容如下:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0modelVersion>
    <parent>
        <groupId>com.enjoyshop.parentgroupId>
        <artifactId>enjoyshop-parentartifactId>
        <version>0.0.1-SNAPSHOTversion>
    parent>
    <groupId>com.enjoyshop.ssogroupId>
    <artifactId>enjoyshop-ssoartifactId>
    <version>1.0.0-SNAPSHOTversion>
    <packaging>warpackaging>

    <dependencies>
        <dependency>
            <groupId>com.enjoyshop.commongroupId>
            <artifactId>enjoyshop-commonartifactId>
            <version>1.0.0-SNAPSHOTversion>
        dependency>
        
        <dependency>
            <groupId>junitgroupId>
            <artifactId>junitartifactId>
            <scope>testscope>
        dependency>

        <dependency>
            <groupId>org.springframeworkgroupId>
            <artifactId>spring-webmvcartifactId>
        dependency>
        <dependency>
            <groupId>org.springframeworkgroupId>
            <artifactId>spring-jdbcartifactId>
        dependency>
        <dependency>
            <groupId>org.springframeworkgroupId>
            <artifactId>spring-aspectsartifactId>
        dependency>

        
        <dependency>
            <groupId>org.mybatisgroupId>
            <artifactId>mybatisartifactId>
        dependency>
        <dependency>
            <groupId>org.mybatisgroupId>
            <artifactId>mybatis-springartifactId>
        dependency>

        
        <dependency>
            <groupId>com.github.abel533groupId>
            <artifactId>mapperartifactId>
        dependency>
        
        <dependency>
            <groupId>mysqlgroupId>
            <artifactId>mysql-connector-javaartifactId>
        dependency>

        <dependency>
            <groupId>org.slf4jgroupId>
            <artifactId>slf4j-log4j12artifactId>
        dependency>

        
        <dependency>
            <groupId>com.fasterxml.jackson.coregroupId>
            <artifactId>jackson-databindartifactId>
        dependency>

        
        <dependency>
            <groupId>com.jolboxgroupId>
            <artifactId>bonecp-springartifactId>
        dependency>

        
        <dependency>
            <groupId>jstlgroupId>
            <artifactId>jstlartifactId>
        dependency>
        <dependency>
            <groupId>javax.servletgroupId>
            <artifactId>servlet-apiartifactId>
            <scope>providedscope>
        dependency>
        <dependency>
            <groupId>javax.servletgroupId>
            <artifactId>jsp-apiartifactId>
            <scope>providedscope>
        dependency>

        
        <dependency>
            <groupId>org.apache.commonsgroupId>
            <artifactId>commons-lang3artifactId>
        dependency>
        <dependency>
            <groupId>org.apache.commonsgroupId>
            <artifactId>commons-ioartifactId>
        dependency>

        
        <dependency>
            <groupId>commons-codecgroupId>
            <artifactId>commons-codecartifactId>
            <version>1.9version>
        dependency>
        
        <dependency>
            <groupId>org.hibernategroupId>
            <artifactId>hibernate-validatorartifactId>
            <version>5.1.3.Finalversion>
        dependency>
    dependencies>



    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.tomcat.mavengroupId>
                <artifactId>tomcat7-maven-pluginartifactId>
                <configuration>
                    <port>8083port>
                    <path>/path>
                configuration>
            plugin>
        plugins>
    build>


project>
  • 配置web.xml

<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://java.sun.com/xml/ns/javaee"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
    id="WebApp_ID" version="2.5">
    <display-name>enjoyshop-ssodisplay-name>

    <context-param>
        <param-name>contextConfigLocationparam-name>
        <param-value>classpath:spring/applicationContext*.xmlparam-value>
    context-param>

    
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListenerlistener-class>
    listener>

    
    <filter>
        <filter-name>encodingFilterfilter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilterfilter-class>
        <init-param>
            <param-name>encodingparam-name>
            <param-value>UTF8param-value>
        init-param>
    filter>
    <filter-mapping>
        <filter-name>encodingFilterfilter-name>
        <url-pattern>/*url-pattern>
    filter-mapping>

    
    <servlet>
        <servlet-name>enjoyshop-ssoservlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServletservlet-class>
        <init-param>
            <param-name>contextConfigLocationparam-name>
            <param-value>classpath:spring/enjoyshop-sso-servlet.xmlparam-value>
        init-param>
        <load-on-startup>1load-on-startup>
    servlet>

    <servlet-mapping>
        <servlet-name>enjoyshop-ssoservlet-name>
        <url-pattern>*.htmlurl-pattern>
    servlet-mapping>
    <servlet-mapping>
        <servlet-name>enjoyshop-ssoservlet-name>
        <url-pattern>/service/*url-pattern>
    servlet-mapping>

    <welcome-file-list>
        <welcome-file>index.jspwelcome-file>
    welcome-file-list>

web-app>
  • 进行SSM整合

这部分内容不作具体描述。可参考具体源码

  • 静态资源文件的引用方式

在sso系统中访问相关的静态资源时,有如下方案可选:
1、 将前台系统中的的js和css拷贝到sso系统中
a) 好处:简单、方便;
b) 缺点:重复、对用户而言需要重复加载。
2、 将sso系统中的引用指向前台系统页面的URL资源
a) 好处:对用户而言只需要加载一次即可;
b) 缺点:修改页面。
3、 通过nginx访问静态资源,例如JS、CSS、Image。
这里采用第三种方式。

  • 配置nginx访问静态资源

1、 添加本地host引用
使用新域名访问静态资源:static.enjoyshop.com
好处:避免携带一些无用的cookie。

127.0.0.1 sso.enjoyshop.com
127.0.0.1 static.enjoyshop.com

2、 拷贝JS和CSS到磁盘路径中
将前台系统中的静态资源目录:js、css、images拷贝到磁盘中。
3、 配置nginx

server {
        listen       80;
        server_name  static.enjoyshop.com;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;

    proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        location / {
        root  E:\\enjoyshop-static;
        }

    }

2、实现用户注册功能

  • 用户表结构
CREATE TABLE `tb_user` (  
  `id` bigint(20) NOT NULL AUTO_INCREMENT,  
  `username` varchar(50) NOT NULL COMMENT '用户名',  
  `password` varchar(32) NOT NULL COMMENT '密码,加密存储',  
  `phone` varchar(20) DEFAULT NULL COMMENT '注册手机号',  
  `email` varchar(50) DEFAULT NULL COMMENT '注册邮箱',  
  `created` datetime NOT NULL,  
  `updated` datetime NOT NULL,  
  PRIMARY KEY (`id`),  
  UNIQUE KEY `username` (`username`) USING BTREE,  
  UNIQUE KEY `phone` (`phone`) USING BTREE,  
  UNIQUE KEY `email` (`email`) USING BTREE  
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8 COMMENT='用户表'  
  • pojo

这里使用Hibernate的validator来做数据校验。

package com.enjoyshop.sso.pojo;

import java.util.Date;

import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.Length;

import com.fasterxml.jackson.annotation.JsonIgnore;

@Table(name = "tb_user")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Length(min = 6, max = 20, message = "用户名的长度必须在6~20位之间!")
    private String username;

    @JsonIgnore//json序列化时忽略该字段
    @Length(min = 6, max = 20, message = "密码的长度必须在6~20位之间!")
    private String password;

    @Length(min = 11, max = 11, message = "手机号的长度必须是11位!")
    private String phone;

    @Email(message = "邮箱格式不符合规则!")
    private String email;

    private Date created;

    private Date updated;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public Date getCreated() {
        return created;
    }

    public void setCreated(Date created) {
        this.created = created;
    }

    public Date getUpdated() {
        return updated;
    }

    public void setUpdated(Date updated) {
        this.updated = updated;
    }

}
  • mapper

这里使用通用mapper

package com.enjoyshop.sso.mapper;

import com.enjoyshop.sso.pojo.User;
import com.github.abel533.mapper.Mapper;

public interface UserMapper extends Mapper<User>{

}
  • service

检测数据可用性

public Boolean check(String param, Integer type) {
        if (type < 1 || type > 3) {
            return null;
        }
        User record = new User();
        switch (type) {
        case 1:
            record.setUsername(param);
            break;
        case 2:
            record.setPhone(param);
            break;
        case 3:
            record.setEmail(param);
            break;
        default:
            break;
        }
        return this.userMapper.selectOne(record) == null;
    }

注册逻辑

public Boolean saveUser(User user) {
        user.setId(null);
        user.setCreated(new Date());
        user.setUpdated(user.getCreated());

        // 密码通过MD5进行加密处理
        user.setPassword(DigestUtils.md5Hex(user.getPassword()));

        return this.userMapper.insert(user) == 1;
    }
  • controller
    检测提交的用户信息是否已注册
@RequestMapping(value = "check/{param}/{type}", method = RequestMethod.GET)
    public ResponseEntity check(@PathVariable("param") String param, @PathVariable("type") Integer type) {
        try {
            Boolean bool = this.userService.check(param, type);
            if (null == bool) {
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
            }
            // 前端逻辑有问题,这里只有用!bool才能得到正确的结果
            return ResponseEntity.ok(!bool);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);

    }

注册逻辑

@RequestMapping(value = "register", method = RequestMethod.GET)
    public String toRegister() {
        return "register";
    }
@RequestMapping(value = "doRegister", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, Object> doRegister(@Valid User user,BindingResult bindingResult) {
        Map<String, Object> result = new HashMap<String, Object>();
        if(bindingResult.hasErrors()){
            //校验有误
            List<String> msgs=new ArrayList<String>();
            List<ObjectError> allErrors = bindingResult.getAllErrors();
            for (ObjectError objectError : allErrors) {
                String msg = objectError.getDefaultMessage();
                msgs.add(msg);
            }
            result.put("status", "400");
            result.put("data", StringUtils.join(msgs, '|'));
            return result;
        }
        Boolean bool = this.userService.saveUser(user);
        if (bool) {
            // 注册成功
            result.put("status", "200");
        } else {
            result.put("status", "300");
            result.put("data", "注册失败,请重新注册!");
        }
        return result;
    }
  • 可能碰到的问题

异常:

org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation
2015-11-19 11:09:57,893 [http-bio-8083-exec-2] [org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver]-[DEBUG] Resolving exception from handler [public org.springframework.http.ResponseEntity.lang.Boolean> com.taotao.sso.controller.UserController.check(java.lang.String,java.lang.Integer)]: 

问题原因:
SpringMVC的规定:在SpringMVC中如果请求以html结尾,那么就不会返回JSON数据。

解决方案:配置多条路径进入SpringMVC

    
    <servlet>
        <servlet-name>enjoyshop-ssoservlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServletservlet-class>
        <init-param>
            <param-name>contextConfigLocationparam-name>
            <param-value>classpath:spring/enjoyshop-sso-servlet.xmlparam-value>
        init-param>
        <load-on-startup>1load-on-startup>
    servlet>

    <servlet-mapping>
        <servlet-name>enjoyshop-ssoservlet-name>
        <url-pattern>*.htmlurl-pattern>
    servlet-mapping>
    <servlet-mapping>
        <servlet-name>enjoyshop-ssoservlet-name>
        <url-pattern>/service/*url-pattern>
    servlet-mapping>

3、实现用户登陆功能

  • service层:
public String doLogin(String username, String password) throws Exception {
        User record = new User();
        record.setUsername(username);
        User user = this.userMapper.selectOne(record);
        if (null == user) {
            return null;
        }
        // 比对密码是否正确
        if (!StringUtils.equals(DigestUtils.md5Hex(password), user.getPassword())) {
            return null;
        }

        // 登录成功
        // 生存token
        String token = DigestUtils.md5Hex(System.currentTimeMillis() + username);

        // 将用户数据保存到redis中
        this.redisService.set("TOKEN_" + token, MAPPER.writeValueAsString(user), 60 * 30);

        return token;
    }
  • controller层
@RequestMapping(value = "doLogin", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, Object> doLogin(@RequestParam("username") String username,
            @RequestParam("password") String password, HttpServletRequest request, HttpServletResponse response) {
        Map<String, Object> result = new HashMap<String, Object>();
        try {
            String token = this.userService.doLogin(username, password);
            if (null == token) {
                // 登录失败
                result.put("status", 400);
            } else {
                // 登录成功,需要将token写入到cookie中
                result.put("status", 200);
                CookieUtils.setCookie(request, response, COOKIE_NAME, token);
            }
        } catch (Exception e) {
            e.printStackTrace();
            // 登录失败
            result.put("status", 500);
        }
        return result;
    }
  • 可能碰到的问题

1、缓存中保存了密码数据,容易造成密码泄露,不安全。
解决方案:使用@JsonIgnore注解,序列化为json时忽略密码项。

    @JsonIgnore//json序列化时忽略该字段
    @Length(min = 6, max = 20, message = "密码的长度必须在6~20位之间!")
    private String password;

2、登录成功后没有写入cookie
原因:代码解析获取到的URL是127.0.0.1,而cookie需要写入到enjoyshop.com中,这样违反了浏览的安全的原则,导致写入失败。
二级域名可以将cookie写入到主域名下。 例如www.enjoyshop.com可以向enjoyshop.com中写入cookie。
二级域名之间不能互相写入。例如www.enjoyshop.com 不能写入到 sso.enjoyshop.com。

解决方案:
只需要通过request对象获取到正确的URL地址(xxx.enjoyshop.com)即可。需要在nginx的配置文件中添加Host的代理头信息:

server {
        listen       80;
        server_name  sso.enjoyshop.com;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;

    proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        #加入头信息,使得tomcat可以正确解析URL地址
        proxy_set_header Host $host;
        location / {
        proxy_pass http://127.0.0.1:8083;
        proxy_connect_timeout 600;
        proxy_read_timeout 600;
        }

    }

4、实现登陆人信息的显示

  • 前台系统中的js展示:
var TT = enjoyshop = {
    checkLogin : function(){
        var _token = $.cookie("TT_TOKEN");
        if(!_token){
            return ;
        }
        $.ajax({
            url : "http://sso.enjoyshop.com/service/user/" + _token,
            dataType : "jsonp",
            type : "GET",
            success : function(_data){
                    var html =_data.username+",欢迎来到乐购![退出]";
                    $("#loginbar").html(html);
            }
        });
    }
}
  • 添加跨域请求支持
    
    <mvc:annotation-driven>
        <mvc:message-converters register-defaults="true">
            <bean
                class="com.enjoyshop.common.spring.exetend.converter.json.CallbackMappingJackson2HttpMessageConverter">
                <property name="callbackName" value="callback" />
            bean>
        mvc:message-converters>
    mvc:annotation-driven>
  • service层

根据token查询信息

public User queryUserByToken(String token) {
        String key = "TOKEN_" + token;
        String jsonData = this.redisService.get(key);
        if (StringUtils.isEmpty(jsonData)) {
            return null;
        }
        try {
            // 刷新用户的生存时间(非常重要)
            this.redisService.expire(key, 60 * 30);
            return MAPPER.readValue(jsonData, User.class);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

controller层

@RequestMapping(value = "{token}", method = RequestMethod.GET)
    public ResponseEntity queryUserByToken(@PathVariable("token") String token) {
        try {
            User user = this.userService.queryUserByToken(token);
            if (null == user) {
                return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
            }
            return ResponseEntity.ok(user);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
    }

你可能感兴趣的:(开源项目)