《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用户与本地用户映射起来,是第三方登录的关键。

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

登录发表评论 注册

tomoya

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

王爵nice

很好的文章

反馈意见