OAuth2 集成

目前很多开放平台如新浪微博开放平台都在使用提供开放 API 接口供开发者使用,随之带来了第三方应用要到开放平台进行授权的问题,OAuth 就是干这个的,OAuth2 是 OAuth 协议的下一个版本,相比 OAuth1,OAuth2 整个授权流程更简单安全了,但不兼容 OAuth1,具体可以到 OAuth2 官网 http://oauth.net/2/ 查看,OAuth2 协议规范可以参考 http://tools.ietf.org/html/rfc6749。目前有好多参考实现供选择,可以到其官网查看下载

本文使用 Apache Oltu实现。使用文档可参考 https://cwiki.apache.org/confluence/display/OLTU/Documentation

OAuth 角色

资源拥有者(resource owner) :能授权访问受保护资源的一个实体,可以是一个人,那我们称之为最终用户;如新浪微博用户 zhangsan;
资源服务器(resource server) :存储受保护资源,客户端通过 access token 请求资源,资源服务器响应受保护资源给客户端;存储着用户 zhangsan 的微博等信息。
授权服务器(authorization server) :成功验证资源拥有者并获取授权之后,授权服务器颁发授权令牌(Access Token)给客户端。
客户端(client) :如新浪微博客户端 weico、微格等第三方应用,也可以是它自己的官方应用;其本身不存储资源,而是资源拥有者授权通过后,使用它的授权(授权令牌)访问受保护资源,然后客户端把相应的数据展示出来 / 提交到服务器。"客户端" 术语不代表任何特定实现(如应用运行在一台服务器、桌面、手机或其他设备)。

  1. 客户端从资源拥有者那请求授权。授权请求可以直接发给资源拥有者,或间接的通过授权服务器这种中介,后者更可取。
  2. 客户端收到一个授权许可,代表资源服务器提供的授权。
  3. 客户端使用它自己的私有证书及授权许可到授权服务器验证。
  4. 如果验证成功,则下发一个访问令牌。
  5. 客户端使用访问令牌向资源服务器请求受保护资源。
  6. 资源服务器会验证访问令牌的有效性,如果成功则下发受保护资源。

更多流程的解释请参考 OAuth2 的协议规范 http://tools.ietf.org/html/rfc6749

服务器端

本文把授权服务器和资源服务器整合在一起实现。

POM 依赖

此处我们使用 apache oltu oauth2 服务端实现,需要引入 authzserver(授权服务器依赖)和 resourceserver(资源服务器依赖)。

<dependency>
    <groupId>org.apache.oltu.oauth2</groupId>
    <artifactId>org.apache.oltu.oauth2.authzserver</artifactId>
    <version>0.31</version>
</dependency>
<dependency>
    <groupId>org.apache.oltu.oauth2</groupId>
    <artifactId>org.apache.oltu.oauth2.resourceserver</artifactId>
    <version>0.31</version>
</dependency>

其他的请参考 pom.xml。

数据字典

用户 (oauth2_user)

|----------|---------|-----|-------| | 名称 | 类型 | 长度 | 描述 | | id | bigint | 10 | 编号 主键 | | username | varchar | 100 | 用户名 | | password | varchar | 100 | 密码 | | salt | varchar | 50 | 盐 |

客户端 (oauth2_client)

|---------------|---------|-----|-----------| | 名称 | 类型 | 长度 | 描述 | | id | bigint | 10 | 编号 主键 | | client_name | varchar | 100 | 客户端名称 | | client_id | varchar | 100 | 客户端 id | | client_secret | varchar | 100 | 客户端安全 key |

用户表存储着认证 / 资源服务器的用户信息,即资源拥有者;比如用户名 / 密码;客户端表存储客户端的的客户端 id 及客户端安全 key;在进行授权时使用。

表及数据 SQL

具体请参考

  • sql/ shiro-schema.sql (表结构)
  • sql/ shiro-data.sql (初始数据)

默认用户名 / 密码是 admin/123456。

实体

具体请参考 com.github.zhangkaitao.shiro.chapter17.entity 包下的实体,此处就不列举了。

DAO

具体请参考 com.github.zhangkaitao.shiro.chapter17.dao 包下的 DAO 接口及实现。

Service

具体请参考 com.github.zhangkaitao.shiro.chapter17.service 包下的 Service 接口及实现。以下是出了基本 CRUD 之外的关键接口:

public interface UserService {
    public User createUser(User user);// 创建用户
    public User updateUser(User user);// 更新用户
    public void deleteUser(Long userId);// 删除用户
    public void changePassword(Long userId, String newPassword); //修改密码
    User findOne(Long userId);// 根据id查找用户
    List<User> findAll();// 得到所有用户
    public User findByUsername(String username);// 根据用户名查找用户
}

public interface ClientService {
    public Client createClient(Client client);// 创建客户端
    public Client updateClient(Client client);// 更新客户端
    public void deleteClient(Long clientId);// 删除客户端
    Client findOne(Long clientId);// 根据id查找客户端
    List<Client> findAll();// 查找所有
    Client findByClientId(String clientId);// 根据客户端id查找客户端
    Client findByClientSecret(String clientSecret);//根据客户端安全KEY查找客户端
}

public interface OAuthService {
   public void addAuthCode(String authCode, String username);// 添加 auth code
   public void addAccessToken(String accessToken, String username); // 添加 access token
   boolean checkAuthCode(String authCode); // 验证auth code是否有效
   boolean checkAccessToken(String accessToken); // 验证access token是否有效
   String getUsernameByAuthCode(String authCode);// 根据auth code获取用户名
   String getUsernameByAccessToken(String accessToken);// 根据access token获取用户名
   long getExpireIn();//auth code / access token 过期时间
   public boolean checkClientId(String clientId);// 检查客户端id是否存在
   public boolean checkClientSecret(String clientSecret);// 坚持客户端安全KEY是否存在
}

此处通过 OAuthService 实现进行 auth code 和 access token 的维护。

后端数据维护控制器

具体请参考 com.github.zhangkaitao.shiro.chapter17.web.controller 包下的 IndexController、LoginController、UserController 和 ClientController,其用于维护后端的数据,如用户及客户端数据;即相当于后台管理。

授权控制器 AuthorizeController

@Controller
public class AuthorizeController {
  @Autowired
  private OAuthService oAuthService;
  @Autowired
  private ClientService clientService;
  @RequestMapping("/authorize")
  public Object authorize(Model model,  HttpServletRequest request)
        throws URISyntaxException, OAuthSystemException {
    try {
      //构建OAuth 授权请求
      OAuthAuthzRequest oauthRequest = new OAuthAuthzRequest(request);
      //检查传入的客户端id是否正确
      if (!oAuthService.checkClientId(oauthRequest.getClientId())) {
        OAuthResponse response = OAuthASResponse
             .errorResponse(HttpServletResponse.SC_BAD_REQUEST)
             .setError(OAuthError.TokenResponse.INVALID_CLIENT)
             .setErrorDescription(Constants.INVALID_CLIENT_DESCRIPTION)
             .buildJSONMessage();
        return new ResponseEntity(
           response.getBody(), HttpStatus.valueOf(response.getResponseStatus()));
      }
      Subject subject = SecurityUtils.getSubject();
      //如果用户没有登录,跳转到登陆页面
      if(!subject.isAuthenticated()) {
        if(!login(subject, request)) {//登录失败时跳转到登陆页面
          model.addAttribute("client",    
              clientService.findByClientId(oauthRequest.getClientId()));
          return "oauth2login";
        }
      }
      String username = (String)subject.getPrincipal();
      //生成授权码
      String authorizationCode = null;
      //responseType目前仅支持CODE,另外还有TOKEN
      String responseType = oauthRequest.getParam(OAuth.OAUTH_RESPONSE_TYPE);
      if (responseType.equals(ResponseType.CODE.toString())) {
        OAuthIssuerImpl oauthIssuerImpl = new OAuthIssuerImpl(new MD5Generator());
        authorizationCode = oauthIssuerImpl.authorizationCode();
        oAuthService.addAuthCode(authorizationCode, username);
      }
      //进行OAuth响应构建
      OAuthASResponse.OAuthAuthorizationResponseBuilder builder =
        OAuthASResponse.authorizationResponse(request, 
                                           HttpServletResponse.SC_FOUND);
      //设置授权码
      builder.setCode(authorizationCode);
      //得到到客户端重定向地址
      String redirectURI = oauthRequest.getParam(OAuth.OAUTH_REDIRECT_URI);
      //构建响应
      final OAuthResponse response = builder.location(redirectURI).buildQueryMessage();
      //根据OAuthResponse返回ResponseEntity响应
      HttpHeaders headers = new HttpHeaders();
      headers.setLocation(new URI(response.getLocationUri()));
      return new ResponseEntity(headers, HttpStatus.valueOf(response.getResponseStatus()));
    } catch (OAuthProblemException e) {
      //出错处理
      String redirectUri = e.getRedirectUri();
      if (OAuthUtils.isEmpty(redirectUri)) {
        //告诉客户端没有传入redirectUri直接报错
        return new ResponseEntity(
          "OAuth callback url needs to be provided by client!!!", HttpStatus.NOT_FOUND);
      }
      //返回错误消息(如?error=)
      final OAuthResponse response =
              OAuthASResponse.errorResponse(HttpServletResponse.SC_FOUND)
                      .error(e).location(redirectUri).buildQueryMessage();
      HttpHeaders headers = new HttpHeaders();
      headers.setLocation(new URI(response.getLocationUri()));
      return new ResponseEntity(headers, HttpStatus.valueOf(response.getResponseStatus()));
    }
  }
  private boolean login(Subject subject, HttpServletRequest request) {
    if("get".equalsIgnoreCase(request.getMethod())) {
      return false;
    }
    String username = request.getParameter("username");
    String password = request.getParameter("password");
    if(StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
      return false;
    }
    UsernamePasswordToken token = new UsernamePasswordToken(username, password);
    try {
      subject.login(token);
      return true;
    } catch (Exception e) {
      request.setAttribute("error", "登录失败:" + e.getClass().getName());
      return false;
    }
  }
}

如上代码的作用:

  1. 首先通过如 http://localhost:8080/chapter17-server/authorize?client_id=c1ebe466-1cdc-4bd3-ab69-77c3561b9dee&response_type=code&redirect_uri=http://localhost:9080/chapter17-client/oauth2-login 访问授权页面;
  2. 该控制器首先检查 clientId 是否正确;如果错误将返回相应的错误信息;
  3. 然后判断用户是否登录了,如果没有登录首先到登录页面登录;
  4. 登录成功后生成相应的 auth code 即授权码,然后重定向到客户端地址,如 http://localhost:9080/chapter17-client/oauth2-login?code=52b1832f5dff68122f4f00ae995da0ed;在重定向到的地址中会带上 code 参数(授权码),接着客户端可以根据授权码去换取 access token。

访问令牌控制器 AccessTokenController

@RestController
public class AccessTokenController {
  @Autowired
  private OAuthService oAuthService;
  @Autowired
  private UserService userService;
  @RequestMapping("/accessToken")
  public HttpEntity token(HttpServletRequest request)
          throws URISyntaxException, OAuthSystemException {
    try {
      //构建OAuth请求
      OAuthTokenRequest oauthRequest = new OAuthTokenRequest(request);
      //检查提交的客户端id是否正确
      if (!oAuthService.checkClientId(oauthRequest.getClientId())) {
        OAuthResponse response = OAuthASResponse
                .errorResponse(HttpServletResponse.SC_BAD_REQUEST)
                .setError(OAuthError.TokenResponse.INVALID_CLIENT)
                .setErrorDescription(Constants.INVALID_CLIENT_DESCRIPTION)
                .buildJSONMessage();
       return new ResponseEntity(
         response.getBody(), HttpStatus.valueOf(response.getResponseStatus()));
      }
    // 检查客户端安全KEY是否正确
      if (!oAuthService.checkClientSecret(oauthRequest.getClientSecret())) {
        OAuthResponse response = OAuthASResponse
              .errorResponse(HttpServletResponse.SC_UNAUTHORIZED)