《OAuth2.0认证和授权机制讲解》中我们知道了现在主流的第三方登陆是怎样一个流程,那么现在,就让我们自己来实现一个通用化的第三方登陆实现吧。

准备工作

在做第三方登陆之前,首先我们当然需要有一个授权服务器承认的第三方应用身份,因此,我们首先前往授权服务器进行申请,由于国内的所有应用都需要进行审核,比较麻烦,这里我们以Github为例,首先申请一个第三方应用的资格。

首先登陆Github账号,进入【Settings】->选择【applications】->选择【Developer applications】,这里我们可以看到当前账户所拥有的第三方应用。

QQ图片20150813170602.png

点击右上角的【Register new application】,按要求填写信息就可以申请一个第三方应用的身份。由于我们是本地调试,我们按照本地的测试地址填写相关信息即可:

application.png

注意右上角的Client Id和Client Secret,这两个信息是用来标识第三方应用身份的相关信息,特别注意Client Secret,Client Secret是用来和授权服务器交换验证凭证(Access Token)的,千万不能暴露出去。

这样,我们就拥有了第三方应用的身份,可以喝Github交互进行OAuth2的授权了。

功能分析

我们的第三方登陆功能实际上就是将OAuth2的授权流程跑通,最后将拿到的用户信息存储在数据库中,并将其与本地数据的用户信息对应起来,以便于下次登陆时直接拿到本地的用户信息。

总结一下OAuth的基本流程,实际上主要涉及到下列URL:

  • 引导用户进行授权的授权地址(authorizationUrl)
  • 用户授权后传递用户凭证(code)的redirectURL
  • 使用用户凭证(code)交换验证凭证(Access Token)的地址
  • 获取用户信息的地址(可能不止一个)

通过确认以上地址,我们可以搭建一个通用化的OAuth授权以及验证功能,不同的授权服务器只需要提供不同的地址,其他流程完全可以用相同的代码来解决,这就是我们今天需要实现的通用化第三方登陆功能的基础。

以Github为例,其相应的API地址分别为:

现在,我们需要做下列事情:

引入依赖

工欲善其事必先利其器,让我们先将我们需要用到的工具添加进来:

 <dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.hsqldb</groupId>
        <artifactId>hsqldb</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.scribe</groupId>
      <artifactId>scribe</artifactId>
      <version>1.3.7</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.2</version>
    </dependency>
</dependencies>

如果大家经常看天码营,应该对前面四个依赖特别熟悉了,前面四个依赖为我们搭建了一个内存数据库+JPA+thymeleaf+Spring的基本web框架。

Scribe是一个简单的Java OAuth库,通过Scribe,可以很方便的实现OAuth验证的功能。

由于通过资源API拿到的用户资源是json编码,因此我们需要fastjson库来处理json对象。

仅仅看这些依赖确实太过抽象,还是看看具体的实现吧。

搭建OAuth用户系统

首先让我们利用JPA搭建一个简单的用户系统,在此基础之上再来做第三方登陆的功能:

@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;
    private String username;
    private String password;

    ......

}

public interface UserRepository extends JpaRepository<User, Integer> {

    User findByUsername(String username);

}

一个User模型,再加上一个Repository接口,我们的用户系统雏形就完成了,想了解更多JPA的同学,可以阅读使用JPA访问关系型数据库,这里就不再介绍了。

有了用户系统后,我们希望本地用户与其在Github或者其他网站的用户信息一一对应起来,因此,我们还需要一张OAuthUser表,里面存有该用户在其他网站的基本信息(在哪个网站,唯一标识是多少)以及该用户与本地用户的映射,其领域模型如下:

@Entity
public class OAuthUser{

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;
    @OneToOne
    private User user;
    private String oAuthType;
    private String oAuthId;

    ......

}

通过User以及OAuthUser对象的建立,我们的数据模型就搭建完成了,接下来便是如何通过OAuth验证。

API

功能分析一节中我们总结了进行Github Oauth验证所需要的几种URL,在Scribe中,将前面三个URL抽象到了接口API中,当我们需要得到相应的URL时,只需要调用API的某个方法即可。Scribe已经为我们实现了很多API,但是很遗憾并没有Github的API,因此,我们需要手动实现GithubAPI。在抽象类DefaultApi20中已经有一些通用的方法,让我们来继承它简化我们的工作:

public class GithubApi extends DefaultApi20 {

    private static final String AUTHORIZE_URL = "https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&state=%s";
    private static final String SCOPED_AUTHORIZE_URL = AUTHORIZE_URL + "&scope=%s";
    private static final String ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token?state=%s";

    private final String githubState;

    public GithubApi(String state){
        this.githubState = state;
    }

    @Override
    public String getAuthorizationUrl(OAuthConfig config) {
        if (config.hasScope()){
          return String.format(SCOPED_AUTHORIZE_URL, config.getApiKey(), OAuthEncoder.encode(config.getCallback()), 
                  githubState, OAuthEncoder.encode(config.getScope()));
        }
        else{
          return String.format(AUTHORIZE_URL, config.getApiKey(), OAuthEncoder.encode(config.getCallback()), githubState);
        }
    }

    @Override
    public String getAccessTokenEndpoint() {
        return String.format(ACCESS_TOKEN_URL, githubState);
    }

}

OAuthService

在Scribe的OAuthService中,定义了OAuth的基本方法,包括获取授权URL、根据code得到access token等,包括OAuth验证中的大部分方法,我们可以很容易的使用OAuthService完成《OAuth验证的前四步》。但是最终通过Access token拿到用户资源还需要我们进行一定的拓展。

public abstract class OAuthServiceDeractor implements OAuthService {

    private final OAuthService oAuthService;
    private final String oAuthType;
    private final String authorizationUrl;

    public OAuthServiceDeractor(OAuthService oAuthService, String type) {
        super();
        this.oAuthService = oAuthService;
        this.oAuthType = type;
        this.authorizationUrl = oAuthService.getAuthorizationUrl(null);
    }

    ......

    public String getoAuthType() {
        return oAuthType;
    }

    public String getAuthorizationUrl(){
        return authorizationUrl;
    }

    public abstract OAuthUser getOAuthUser(Token accessToken);

}

最终,我们通过装饰者模式为OAuthService添加了三个方法:

  • getoAuthType() 获取该Service的type
  • getAuthorizationUrl() 获取authorizationUrl,方便前端展示
  • getOAuthUser(Token accessToken) 根据access token拿到用户资源,并将其转换为对应的OAuthUser

这样,我们就能使用同一个方法来获取用户的相关资源。对于Github来说,需要实现getOAuthUser一个方法:

public class GithubOAuthService extends OAuthServiceDeractor {

    private static final String PROTECTED_RESOURCE_URL = "https://api.github.com/user";

    public GithubOAuthService(OAuthService oAuthService) {
        super(oAuthService, OAuthTypes.GITHUB);
    }

    @Override
    public OAuthUser getOAuthUser(Token accessToken) {
        OAuthRequest request = new OAuthRequest(Verb.GET, PROTECTED_RESOURCE_URL);
        this.signRequest(accessToken, request);
        Response response = request.send();
        OAuthUser oAuthUser = new OAuthUser();
        oAuthUser.setoAuthType(getoAuthType());
        Object result = JSON.parse(response.getBody());
        oAuthUser.setoAuthId(JSONPath.eval(result, "$.id").toString());
        oAuthUser.setUser(new User());
        oAuthUser.getUser().setUsername(JSONPath.eval(result, "$.login").toString());
        return oAuthUser;
    }

}

如果我们有很多第三方登陆的接口,我们将通过OAuthServices进行管理,我们通过Spring的依赖式注入获取所有OAuthService,并且通过OAuthType区别不同的OAuthService:

@Service
public class OAuthServices {

    @Autowired List<OAuthServiceDeractor> oAuthServiceDeractors;

    public OAuthServiceDeractor getOAuthService(String type){
        Optional<OAuthServiceDeractor> oAuthService = oAuthServiceDeractors.stream().filter(o -> o.getoAuthType().equals(type))
                .findFirst();
        if(oAuthService.isPresent()){
            return oAuthService.get();
        }
        return null;
    }

    public List<OAuthServiceDeractor> getAllOAuthServices(){
        return oAuthServiceDeractors;
    }

}

通过OAuthService,我们可以很方便的调用OAuth服务。

Controller

最后,我们要写一个Controller供用户访问:

@Controller
public class AccountController {

    public static final Logger logger = LoggerFactory.getLogger(AccountController.class);

    @Autowired OAuthServices oAuthServices;
    @Autowired OauthUserRepository oauthUserRepository;
    @Autowired UserRepository userRepository;

    @RequestMapping(value = {"", "/login"}, method=RequestMethod.GET)
    public String showLogin(Model model){
        model.addAttribute("oAuthServices", oAuthServices.getAllOAuthServices());
        return "index";
    }

    @RequestMapping(value = "/oauth/{type}/callback", method=RequestMethod.GET)
    public String claaback(@RequestParam(value = "code", required = true) String code,
            @PathVariable(value = "type") String type,
            HttpServletRequest request, Model model){
        OAuthServiceDeractor oAuthService = oAuthServices.getOAuthService(type);
        Token accessToken = oAuthService.getAccessToken(null, new Verifier(code));
        OAuthUser oAuthInfo = oAuthService.getOAuthUser(accessToken);
        OAuthUser oAuthUser = oauthUserRepository.findByOAuthTypeAndOAuthId(oAuthInfo.getoAuthType(), 
                oAuthInfo.getoAuthId());
        if(oAuthUser == null){
            model.addAttribute("oAuthInfo", oAuthInfo);
            return "register";
        }
        request.getSession().setAttribute("oauthUser", oAuthUser);
        return "redirect:/success";
    }

    @RequestMapping(value = "/register", method=RequestMethod.POST)
    public String register(Model model, User user,
            @RequestParam(value = "oAuthType", required = false, defaultValue = "") String oAuthType,
            @RequestParam(value = "oAuthId", required = true, defaultValue = "") String oAuthId,
            HttpServletRequest request){
        OAuthUser oAuthInfo = new OAuthUser();
        oAuthInfo.setoAuthId(oAuthId);
        oAuthInfo.setoAuthType(oAuthType);
        if(userRepository.findByUsername(user.getUsername()) != null){
            model.addAttribute("errorMessage", "用户名已存在");
            model.addAttribute("oAuthInfo", oAuthInfo);
            return "register";
        }
        user = userRepository.save(user);
        OAuthUser oAuthUser = oauthUserRepository.findByOAuthTypeAndOAuthId(oAuthType, oAuthId);
        if(oAuthUser == null){
            oAuthInfo.setUser(user);
            oAuthUser = oauthUserRepository.save(oAuthInfo);
        }
        request.getSession().setAttribute("oauthUser", oAuthUser);
        return "redirect:/success";
    }

    @RequestMapping(value = "/success", method=RequestMethod.GET)
    @ResponseBody
    public Object success(HttpServletRequest request){
        return request.getSession().getAttribute("oauthUser");
    }

}

最终,我们为用户提供了4个URL:

  • /login

用户登录页面,为了简化处理,在该页面我们只展示第三方登录的地址

  • /oauth/{type}/callback

用户授权后的redirect_uri,授权服务器会通知浏览器跳转到该地址,并附带有用户凭证(code)

在该请求中,我们将得到cod->根据code拿到Access Token->根据Access Token拿到授权用户信息->根据授权用户获取本地用户->如果本地用户存在,直接登录,跳转到/success页面->如果本地用户不存在,跳转到注册页面引导用户注册。

  • /register

注册页面,该页面将获取本地用户系统所需要的相关信息,以及OAuth的相关信息,存入数据库,最后将用户跳转到/success页面。

  • /success

最终的登录成功页面,将展示用户以及OAuth的相关信息。

以上几个请求中,我们需要特别关注 /oauth/{type}/callback 以及/register。其中/oauth/{type}/callback负责OAuth相关的所有事宜,/register负责将OAuth用户与本地用户映射起来,是第三方登录的关键。

怎么样,第三方登录是不是很简单,快自己动手实现一下吧。

登录发表评论 注册

jawetech

您好 源码点击进去显示文件已失效,可否提供源码参考一下呀,感谢

帕兮陆

第一次授权成功后 以后点击github的授权链接 都是直接登录的 如果本应用退出 github的账号也跟着退出 要怎么做到 ??不然 一直默认一开始的github账号登录 不能切换github账号登录 必须清除客户端的浏览数据以及缓存才能重新进入github登陆的界面 。有办法做到吗??

leongfeng

看了 github 文档里面就这一句话 An unguessable random string. It is used to protect against cross-site request forgery attacks.,然而自己随便加密一段字符串不行呢

tomoya

这个好像没用spring-boot-security里的oauth2,而是自己封装的一套

王爵nice

很好的文章

反馈意见