1. 程式人生 > >單點登入系統開發

單點登入系統開發

一、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.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<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 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<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。

解決方案:
只需要通過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+",歡迎來到樂購!<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);
    }