Shiro 集成 OAuth2

JAVA herman 1264浏览 0评论
公告:“业余草”微信公众号提供免费CSDN下载服务(只下Java资源),关注业余草微信公众号,添加作者微信:xttblog,发送下载链接帮助你免费下载!
本博客日IP超过1800,PV 2600 左右,急需赞助商。
极客时间所有课程通过我的二维码购买后返现24元微信红包,请加博主新的微信号:xttblog,之前的微信号好友位已满,备注:返现
所有面试题(java、前端、数据库、springboot等)一网打尽,请关注文末小程序
视频教程免费领

昨天还有一位网友问我OAuth2 相关的知识,由于时间有限,我没有详细的讲解。今天我们使用Shiro 来整合OAuth2 来配套学习。

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

本案例使用Apache Oltu,其之前的名字叫Apache Amber ,是Java版的参考实现。使用文档可参考https://cwiki.apache.org/confluence/display/OLTU/Documentation

OAuth 角色

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

OAuth2协议流程

OAuth2 协议流程

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

更多流程的解释请参考OAuth2的协议规范http://tools.ietf.org/html/rfc6749。有不懂得,也可以看看阮一峰的OAuth2原理:http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html

POM 依赖

此处我们使用apache oltu oauth2服务端实现,需要引入authzserver(授权服务器依赖)和resourceserver(资源服务器依赖)。数据字典请参考源码中的db/shiro-schema.sql (表结构)和db/shiro-data.sql  (初始数据)。

默认用户名/密码是admin/123456。实体类和dao、service层代码请查看源码,为了篇幅和重点这里省略。

授权控制器 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/shiro16-server/authorize
    ?client_id=c1ebe466-1cdc-4bd3-ab69-77c3561b9dee&response_type=code&redirect_uri=http://localhost:9080/shiro16-client/oauth2-login访问授权页面;
  2. 该控制器首先检查clientId是否正确;如果错误将返回相应的错误信息;
  3. 然后判断用户是否登录了,如果没有登录首先到登录页面登录;
  4. 登录成功后生成相应的auth code即授权码,然后重定向到客户端地址,如http://localhost:9080/shiro16-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)  
              .setError(OAuthError.TokenResponse.UNAUTHORIZED_CLIENT)  
              .setErrorDescription(Constants.INVALID_CLIENT_DESCRIPTION)  
              .buildJSONMessage();  
      return new ResponseEntity(  
          response.getBody(), HttpStatus.valueOf(response.getResponseStatus()));  
      }  
    
      String authCode = oauthRequest.getParam(OAuth.OAUTH_CODE);  
      // 检查验证类型,此处只检查AUTHORIZATION_CODE类型,其他的还有PASSWORD或REFRESH_TOKEN  
      if (oauthRequest.getParam(OAuth.OAUTH_GRANT_TYPE).equals(  
         GrantType.AUTHORIZATION_CODE.toString())) {  
         if (!oAuthService.checkAuthCode(authCode)) {  
            OAuthResponse response = OAuthASResponse  
                .errorResponse(HttpServletResponse.SC_BAD_REQUEST)  
                .setError(OAuthError.TokenResponse.INVALID_GRANT)  
                .setErrorDescription("错误的授权码")  
              .buildJSONMessage();  
           return new ResponseEntity(  
             response.getBody(), HttpStatus.valueOf(response.getResponseStatus()));  
         }  
      }  
  
      //生成Access Token  
      OAuthIssuer oauthIssuerImpl = new OAuthIssuerImpl(new MD5Generator());  
      final String accessToken = oauthIssuerImpl.accessToken();  
      oAuthService.addAccessToken(accessToken,  
          oAuthService.getUsernameByAuthCode(authCode));  
  
      //生成OAuth响应  
      OAuthResponse response = OAuthASResponse  
              .tokenResponse(HttpServletResponse.SC_OK)  
              .setAccessToken(accessToken)  
              .setExpiresIn(String.valueOf(oAuthService.getExpireIn()))  
              .buildJSONMessage();  
  
      //根据OAuthResponse生成ResponseEntity  
      return new ResponseEntity(  
          response.getBody(), HttpStatus.valueOf(response.getResponseStatus()));  
    } catch (OAuthProblemException e) {  
      //构建错误响应  
      OAuthResponse res = OAuthASResponse  
              .errorResponse(HttpServletResponse.SC_BAD_REQUEST).error(e)  
              .buildJSONMessage();  
     return new ResponseEntity(res.getBody(), HttpStatus.valueOf(res.getResponseStatus()));  
   }  
 }  
}   

代码解释:

  1. 首先通过如http://localhost:8080/shiro16-server/accessToken,POST提交如下数据:client_id= c1ebe466-1cdc-4bd3-ab69-77c3561b9dee& client_secret= d8346ea2-6017-43ed-ad68-19c0f971738b&grant_type=authorization_code&code=828beda907066d058584f37bcfd597b6&redirect_uri=http://localhost:9080/shiro16-client/oauth2-login访问;
    2、该控制器会验证client_id、client_secret、auth code的正确性,如果错误会返回相应的错误;
    3、如果验证通过会生成并返回相应的访问令牌access token。

资源控制器UserInfoController

@RestController  
public class UserInfoController {  
  @Autowired  
  private OAuthService oAuthService;  
  
  @RequestMapping("/userInfo")  
  public HttpEntity userInfo(HttpServletRequest request) throws OAuthSystemException {  
    try {  
      //构建OAuth资源请求  
      OAuthAccessResourceRequest oauthRequest =   
            new OAuthAccessResourceRequest(request, ParameterStyle.QUERY);  
      //获取Access Token  
      String accessToken = oauthRequest.getAccessToken();  
  
      //验证Access Token  
      if (!oAuthService.checkAccessToken(accessToken)) {  
        // 如果不存在/过期了,返回未验证错误,需重新验证  
      OAuthResponse oauthResponse = OAuthRSResponse  
              .errorResponse(HttpServletResponse.SC_UNAUTHORIZED)  
              .setRealm(Constants.RESOURCE_SERVER_NAME)  
              .setError(OAuthError.ResourceResponse.INVALID_TOKEN)  
              .buildHeaderMessage();  
  
        HttpHeaders headers = new HttpHeaders();  
        headers.add(OAuth.HeaderType.WWW_AUTHENTICATE,   
          oauthResponse.getHeader(OAuth.HeaderType.WWW_AUTHENTICATE));  
      return new ResponseEntity(headers, HttpStatus.UNAUTHORIZED);  
      }  
      //返回用户名  
      String username = oAuthService.getUsernameByAccessToken(accessToken);  
      return new ResponseEntity(username, HttpStatus.OK);  
    } catch (OAuthProblemException e) {  
      //检查是否设置了错误码  
      String errorCode = e.getError();  
      if (OAuthUtils.isEmpty(errorCode)) {  
        OAuthResponse oauthResponse = OAuthRSResponse  
               .errorResponse(HttpServletResponse.SC_UNAUTHORIZED)  
               .setRealm(Constants.RESOURCE_SERVER_NAME)  
               .buildHeaderMessage();  
  
        HttpHeaders headers = new HttpHeaders();  
        headers.add(OAuth.HeaderType.WWW_AUTHENTICATE,   
          oauthResponse.getHeader(OAuth.HeaderType.WWW_AUTHENTICATE));  
        return new ResponseEntity(headers, HttpStatus.UNAUTHORIZED);  
      }  
  
      OAuthResponse oauthResponse = OAuthRSResponse  
               .errorResponse(HttpServletResponse.SC_UNAUTHORIZED)  
               .setRealm(Constants.RESOURCE_SERVER_NAME)  
               .setError(e.getError())  
               .setErrorDescription(e.getDescription())  
               .setErrorUri(e.getUri())  
               .buildHeaderMessage();  
  
      HttpHeaders headers = new HttpHeaders();  
      headers.add(OAuth.HeaderType.WWW_AUTHENTICATE, 、  
        oauthResponse.getHeader(OAuth.HeaderType.WWW_AUTHENTICATE));  
      return new ResponseEntity(HttpStatus.BAD_REQUEST);  
    }  
  }  
}   

代码解释:

  1. 首先通过如http://localhost:8080/shiro16-server/userInfo? access_token=828beda907066d058584f37bcfd597b6进行访问;
  2. 该控制器会验证access token的有效性;如果无效了将返回相应的错误,客户端再重新进行授权;
  3. 如果有效,则返回当前登录用户的用户名。

关键代码已解释,剩下的spring配置文件等见源码。

客户端流程

如果需要登录首先跳到oauth2服务端进行登录授权,成功后服务端返回auth code,然后客户端使用auth code去服务器端换取access token,最好根据access token获取用户信息进行客户端的登录绑定。这个可以参照如很多网站的新浪微博登录功能,或其他的第三方帐号登录功能。

关键pom配置依赖如下:

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

OAuth2Token

类似于UsernamePasswordToken和CasToken;用于存储oauth2服务端返回的auth code。

OAuth2AuthenticationFilter

该filter的作用类似于FormAuthenticationFilter用于oauth2客户端的身份验证控制;如果当前用户还没有身份验证,首先会判断url中是否有code(服务端返回的auth code),如果没有则重定向到服务端进行登录并授权,然后返回auth code;接着OAuth2AuthenticationFilter会用auth code创建OAuth2Token,然后提交给Subject.login进行登录;接着OAuth2Realm会根据OAuth2Token进行相应的登录逻辑。

public class OAuth2AuthenticationFilter extends AuthenticatingFilter {  
    //oauth2 authc code参数名  
    private String authcCodeParam = "code";  
    //客户端id  
    private String clientId;  
    //服务器端登录成功/失败后重定向到的客户端地址  
    private String redirectUrl;  
    //oauth2服务器响应类型  
    private String responseType = "code";  
    private String failureUrl;  
    //省略setter  
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {  
        HttpServletRequest httpRequest = (HttpServletRequest) request;  
        String code = httpRequest.getParameter(authcCodeParam);  
        return new OAuth2Token(code);  
    }  
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {  
        return false;  
    }  
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {  
        String error = request.getParameter("error");  
        String errorDescription = request.getParameter("error_description");  
        if(!StringUtils.isEmpty(error)) {//如果服务端返回了错误  
            WebUtils.issueRedirect(request, response, failureUrl + "?error=" + error + "error_description=" + errorDescription);  
            return false;  
        }  
        Subject subject = getSubject(request, response);  
        if(!subject.isAuthenticated()) {  
            if(StringUtils.isEmpty(request.getParameter(authcCodeParam))) {  
                //如果用户没有身份验证,且没有auth code,则重定向到服务端授权  
                saveRequestAndRedirectToLogin(request, response);  
                return false;  
            }  
        }  
        //执行父类里的登录逻辑,调用Subject.login登录  
        return executeLogin(request, response);  
    }  
  
    //登录成功后的回调方法 重定向到成功页面  
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request,  ServletResponse response) throws Exception {  
        issueSuccessRedirect(request, response);  
        return false;  
    }  
  
    //登录失败后的回调   
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException ae, ServletRequest request,  
                                     ServletResponse response) {  
        Subject subject = getSubject(request, response);  
        if (subject.isAuthenticated() || subject.isRemembered()) {  
            try { //如果身份验证成功了 则也重定向到成功页面  
                issueSuccessRedirect(request, response);  
            } catch (Exception e) {  
                e.printStackTrace();  
            }  
        } else {  
            try { //登录失败时重定向到失败页面  
                WebUtils.issueRedirect(request, response, failureUrl);  
            } catch (IOException e) {  
                e.printStackTrace();  
            }  
        }  
        return false;  
    }  
}   

该拦截器的作用:

  1. 首先判断有没有服务端返回的error参数,如果有则直接重定向到失败页面;
  2. 接着如果用户还没有身份验证,判断是否有auth code参数(即是不是服务端授权之后返回的),如果没有则重定向到服务端进行授权;
  3. 否则调用executeLogin进行登录,通过auth code创建OAuth2Token提交给Subject进行登录;
  4. 登录成功将回调onLoginSuccess方法重定向到成功页面;
  5. 登录失败则回调onLoginFailure重定向到失败页面。

测试步骤:

  1. 首先访问http://localhost:9080/shiro16-client/,然后点击登录按钮进行登录。
  2. 输入用户名进行登录并授权;
  3. 如果登录成功,服务端会重定向到客户端,即之前客户端提供的地址http://localhost:9080/shiro16-client/oauth2-login?code=473d56015bcf576f2ca03eac1a5bcc11,并带着auth code过去;
  4. 客户端的OAuth2AuthenticationFilter会收集此auth code,并创建OAuth2Token提交给Subject进行客户端登录;
  5. 客户端的Subject会委托给OAuth2Realm进行身份验证;此时OAuth2Realm会根据auth code换取access token,再根据access token获取受保护的用户信息;然后进行客户端登录。

到此OAuth2的集成就完成了,此处的服务端和客户端相对比较简单,没有进行一些异常检测,请参考如新浪微博进行相应API及异常错误码的设计。

本文源代码案例下载链接:http://pan.baidu.com/s/1c2pFRsG 密码:r6t8

业余草公众号

最后,欢迎关注我的个人微信公众号:业余草(yyucao)!可加QQ1群:135430763(2000人群已满),QQ2群:454796847(已满),QQ3群:187424846(已满)。QQ群进群密码:xttblog,想加微信群的朋友,之前的微信号好友已满,请加博主新的微信号:xttblog,备注:“xttblog”,添加博主微信拉你进群。备注错误不会同意好友申请。再次感谢您的关注!后续有精彩内容会第一时间发给您!原创文章投稿请发送至532009913@qq.com邮箱。商务合作可添加助理微信进行沟通!

本文原文出处:业余草: » Shiro 集成 OAuth2