2012-08-05

使用 JOpenID 登入 Google 或 Yahoo

OpenId 就是讓網站可以接受來自 Google 或  Yahoo 這類提供 OpenId 服務的帳號,簡單講,就是在 Google 登入之後,可以直接登入你的網站,或者在登入你的網站時請他先登入 Google,然後 Google 會回傳一些基本資料供你的網站使用。

JOpenId 就是 OpenId 的一種實做,千萬別用 OpenId4J,太底層了。

這邊是用 Spring MVC 3.1.2 做 Webapp。

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/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>idv.neil.springMVC312</groupId>
  <artifactId>springMVC312</artifactId>
  <packaging>war</packaging>
  <version>1.0-SNAPSHOT</version>
  <name>springMVC312 Maven Webapp</name>
  <url>http://maven.apache.org</url>
  <dependencies>
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>servlet-api</artifactId>
      <version>2.5</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>jsp-api</artifactId>
      <version>2.0</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>jstl</artifactId>
      <version>1.2</version>
    </dependency>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-webmvc</artifactId>
      <version>3.1.2.RELEASE</version>
    </dependency>
    <dependency>
      <groupId>org.glassfish.hk2.external</groupId>
      <artifactId>javax.inject</artifactId>
      <version>2.1.13</version>
    </dependency>
    <dependency>
      <groupId>org.expressme</groupId>
      <artifactId>JOpenId</artifactId>
      <version>1.08</version>
    </dependency>
  </dependencies>
  <build>
    <finalName>springMVC312</finalName>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-eclipse-plugin</artifactId>
        <configuration>
          <!-- 更改 output 目錄 -->
          <buildOutputDirectory>src/main/webapp/WEB-INF/classes</buildOutputDirectory>
          <!-- 讓產生的 .classpath 包含 source jar -->
          <downloadSources>true</downloadSources>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>2.5.1</version>
        <configuration>
          <source>1.5</source>
          <target>1.5</target>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-war-plugin</artifactId>
        <version>2.2</version>
      </plugin>
    </plugins>
  </build>
</project>

/WEB-INF/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"
  xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
  xsi:schemaLocation="
    http://java.sun.com/xml/ns/javaee 
    http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
  version="2.5">
  <context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>/WEB-INF/spring-app.xml</param-value>
  </context-param>
  <listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
  </listener>
  <servlet>
    <servlet-name>springMVC312</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
      <param-name>contextConfigLocation</param-name>
      <param-value>/WEB-INF/spring-web.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>springMVC312</servlet-name>
    <url-pattern>/</url-pattern>
  </servlet-mapping>
  <welcome-file-list>
    <welcome-file>index.jsp</welcome-file>
  </welcome-file-list>
</web-app>

/WEB-INF/spring-web.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context"
  xmlns:mvc="http://www.springframework.org/schema/mvc"
  xsi:schemaLocation="
    http://www.springframework.org/schema/beans 
    http://www.springframework.org/schema/beans/spring-beans-3.0.xsd 
    http://www.springframework.org/schema/context 
    http://www.springframework.org/schema/context/spring-context-3.0.xsd
    http://www.springframework.org/schema/mvc 
    http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd ">

  <mvc:annotation-driven />

  <context:component-scan base-package="idv.neil.web" />

  <bean
    class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="viewClass"
      value="org.springframework.web.servlet.view.JstlView" />
    <property name="prefix" value="/WEB-INF/views/" />
    <property name="suffix" value=".jsp" />
  </bean>

</beans>

/WEB-INF/views/jopenid/index.jsp
<?xml version="1.0" encoding="UTF-8" ?>
<%@ page language="java" contentType="text/html; charset=BIG5" pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Index</title>
</head>
<body>
<a href="./login?op=Google">Login Google</a>
<a href="./login?op=Yahoo">Login Yahoo</a>
</body>
</html>

JOpenIDCtrl.java
package idv.neil.web;

import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.HashSet;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.expressme.openid.Association;
import org.expressme.openid.Authentication;
import org.expressme.openid.Endpoint;
import org.expressme.openid.OpenIdException;
import org.expressme.openid.OpenIdManager;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

/**
 * @author Neil Chan
 * @date 2012/8/2
 */
@Controller
@RequestMapping("/jopenid")
public class JOpenIDCtrl {

  private static final String ATTR_MAC = "openid_mac";
  private static final String ATTR_ALIAS = "openid_alias";
  private static final long ONE_HOUR = 3600000L;
  private static final long TWO_HOUR = ONE_HOUR * 2L;
  // private static final long TWO_HOUR = 30 * 1000;
  /** simulate a database that store all nonce */
  private Set<String> nonceDb = new HashSet<String>();
  private OpenIdManager manager;

  private JOpenIDCtrl() {
    super();
    this.manager = new OpenIdManager();
    this.manager.setRealm("http://localhost/springMVC312/");
    // 成功登入後回來的 url
    this.manager.setReturnTo("http://localhost/springMVC312/jopenid/home");
  }

  @RequestMapping("/index")
  public String index(Model model) {
    return "jopenid/index";
  }

  @RequestMapping("/login")
  public void login(@RequestParam("op") String op, HttpSession session, HttpServletRequest request, HttpServletResponse response) throws IOException {
    if (op.equals("Google") || op.equals("Yahoo")) {
      // 到 JOpenId-1.08.jar/openid-providers.properties 找出 provider url
      // 若要新增其他 provider url,可以在 WEB-INF/classes 下新增 openid-providers.properties 檔案
      Endpoint endpoint = manager.lookupEndpoint(op);
      Association association = manager.lookupAssociation(endpoint);
      session.setAttribute(ATTR_MAC, association.getRawMacKey());
      session.setAttribute(ATTR_ALIAS, endpoint.getAlias());
      String url = manager.getAuthenticationUrl(endpoint, association);
      response.sendRedirect(url);
    }
    else {
      throw new RuntimeException("Unsupported OP: " + op);
    }
  }

  @RequestMapping("/home")
  public String home(@RequestParam("openid.response_nonce") String nonce, Model model, HttpSession session, HttpServletRequest request, HttpServletResponse response) {

    // check sign on result from Google or Yahoo:
    this.checkNonce(nonce);

    // get authentication:
    byte[] mac_key = (byte[]) session.getAttribute(ATTR_MAC);
    String alias = (String) session.getAttribute(ATTR_ALIAS);
    Authentication authentication = manager.getAuthentication(request, mac_key, alias);

    model.addAttribute("identity", authentication.getIdentity());
    model.addAttribute("email", authentication.getEmail());
    model.addAttribute("fullname", authentication.getFullname());
    model.addAttribute("firstname", authentication.getFirstname());
    model.addAttribute("lastname", authentication.getLastname());
    model.addAttribute("gender", authentication.getGender());
    model.addAttribute("language", authentication.getLanguage());
    return "jopenid/home";
  }

  /**
   * 隨機數檢查 - 用來防止 Replay Attack,也就是駭客攔截該 response,並用來再次登入
   * 所以要檢查時間不可以超過一小時,以及該隨機數已經使用過
   * 隨機數長得像 2009-10-21T02:11:39Zrhco-EsNzi8FtQ 這樣
   * 2009-10-21T02:11:39 是當下時間,Zrhco-EsNzi8FtQ 是隨機產生
   * 
   * @param nonce
   */
  private void checkNonce(String nonce) {
    // check response_nonce to prevent replay-attack:
    if (nonce == null || nonce.length() < 20) {
      throw new OpenIdException("Verify failed.");
    }
    SimpleDateFormat dateFormat = this.createDateFormat();
    // make sure the time of server is correct:
    long nonceTime = this.getNonceTime(nonce, dateFormat);
    long diff = Math.abs(System.currentTimeMillis() - nonceTime);
    if (diff > ONE_HOUR) {
      throw new OpenIdException("Bad nonce time.");
    }
    if (this.isNonceExist(nonce)) {
      throw new OpenIdException("Verify nonce failed.");
    }
    this.storeNonce(nonce, dateFormat);
  }

  private long getNonceTime(String nonce, SimpleDateFormat dateFormat) {
    try {
      return dateFormat.parse(nonce.substring(0, 19) + "+0000").getTime();
    }
    catch (ParseException e) {
      throw new OpenIdException("Bad nonce time.");
    }
  }

  private SimpleDateFormat createDateFormat() {
    return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ");
  }

  /**
   * check if nonce is exist in database
   * 
   * @param nonce
   * @return
   */
  private boolean isNonceExist(String nonce) {
    return this.nonceDb.contains(nonce);
  }

  /**
   * store nonce in database
   * 
   * @param nonce
   * @param expires
   */
  private void storeNonce(String nonce, SimpleDateFormat dateFormat) {
    // 清掉舊資料
    long target = System.currentTimeMillis() - TWO_HOUR;
    for (String s : this.nonceDb) {
      long stime = this.getNonceTime(s, dateFormat);
      if (stime < target) {
        System.out.println("Remove " + s);
        this.nonceDb.remove(s);
      }
      else {
        System.out.println("Keep " + s);
      }
    }
    this.nonceDb.add(nonce);
  }
}

/WEB-INF/views/jopenid/home.jsp
<?xml version="1.0" encoding="UTF-8" ?>
<%@ page language="java" contentType="text/html; charset=BIG5" pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Home</title>
</head>
<body>
Back home~<br/>
identity: <c:out value="${identity}"/><br/>
email: <c:out value="${email}"/><br/>
fullname: <c:out value="${fullname}"/><br/>
firstname: <c:out value="${firstname}"/><br/>
lastname: <c:out value="${lastname}"/><br/>
gender: <c:out value="${gender}"/><br/>
language: <c:out value="${language}"/><br/>
</body>
</html>

---

沒有留言:

張貼留言