单点登录系统开发(转载)

Posted 蓝假儿

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了单点登录系统开发(转载)相关的知识,希望对你有一定的参考价值。

每天叫醒你的不是闹钟,而是梦想

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

基本介绍

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

解决方案

只要解决了以上的问题,达到了开头讲得效果就可以说是SSO。最简单实现SSO的方法就是用Cookie,实现流程如下所示: 

以上的方案是把信任存储在客户端的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.0</modelVersion>
    <parent>
        <groupId>com.enjoyshop.parent</groupId>
        <artifactId>enjoyshop-parent</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <groupId>com.enjoyshop.sso</groupId>
    <artifactId>enjoyshop-sso</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>war</packaging>

    <dependencies>
        <dependency>
            <groupId>com.enjoyshop.common</groupId>
            <artifactId>enjoyshop-common</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
        <!-- 单元测试 -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-aspects</artifactId>
        </dependency>

        <!-- Mybatis -->
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis-spring</artifactId>
        </dependency>

        <!-- 通用Mapper -->
        <dependency>
            <groupId>com.github.abel533</groupId>
            <artifactId>mapper</artifactId>
        </dependency>
        <!-- mysql -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
        </dependency>

        <!-- Jackson Json处理工具包 -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>

        <!-- 连接池 -->
        <dependency>
            <groupId>com.jolbox</groupId>
            <artifactId>bonecp-spring</artifactId>
        </dependency>

        <!-- JSP相关 -->
        <dependency>
            <groupId>jstl</groupId>
            <artifactId>jstl</artifactId>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>servlet-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>jsp-api</artifactId>
            <scope>provided</scope>
        </dependency>

        <!-- Apache工具组件 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-io</artifactId>
        </dependency>

        <!-- 加密解密的工具 -->
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
            <version>1.9</version>
        </dependency>
        <!-- 数据校验 -->
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-validator</artifactId>
            <version>5.1.3.Final</version>
        </dependency>
    </dependencies>



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

  • 配置web.xml

<?xml version="1.0" encoding="UTF-8"?><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-sso</display-name>

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath:spring/applicationContext*.xml</param-value>
    </context-param>

    <!--Spring的ApplicationContext 载入 -->
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <!-- 编码过滤器,以UTF8编码 -->
    <filter>
        <filter-name>encodingFilter</filter-name>
        <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF8</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>encodingFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <!-- 配置SpringMVC框架入口 -->
    <servlet>
        <servlet-name>enjoyshop-sso</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:spring/enjoyshop-sso-servlet.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>enjoyshop-sso</servlet-name>
        <url-pattern>*.html</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>enjoyshop-sso</servlet-name>
        <url-pattern>/service/*</url-pattern>
    </servlet-mapping>

    <welcome-file-list>
        <welcome-file>index.jsp</welcome-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.com127.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<Boolean> 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 representation2015-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<java.lang.Boolean> com.taotao.sso.controller.UserController.check(java.lang.String,java.lang.Integer)]: 
 
   
   
 

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

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

    <!-- 配置SpringMVC框架入口 -->
    <servlet>
        <servlet-name>enjoyshop-sso</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:spring/enjoyshop-sso-servlet.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>enjoyshop-sso</servlet-name>
        <url-pattern>*.html</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
        <servlet-name>enjoyshop-sso</servlet-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。


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+",欢迎来到乐购!<a href=\"http://www.enjoyshop.com/user/logout.html\" class=\"link-logout\">[退出]</a>";
                    $("#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<User> 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);
    }


以上是关于单点登录系统开发(转载)的主要内容,如果未能解决你的问题,请参考以下文章

构建和实现单点登录解决方案(转载于IBMdeveloperWorks)

如何在分布式环境中搭建单点登录系统| 第二篇:基于Oauth2.0开发SSO核心代码

单点登录是怎么回事

SSO单点登录思路

八幅漫画理解使用JSON Web Token设计单点登录系统

Web系统之常用单点登录协议