在《OAuth2.0认证和授权机制讲解》中我们知道了现在主流的第三方登陆是怎样一个流程,那么现在,就让我们自己来实现一个通用化的第三方登陆实现吧。
准备工作
在做第三方登陆之前,首先我们当然需要有一个授权服务器承认的第三方应用身份,因此,我们首先前往授权服务器进行申请,由于国内的所有应用都需要进行审核,比较麻烦,这里我们以Github为例,首先申请一个第三方应用的资格。
首先登陆Github账号,进入【Settings】->选择【applications】->选择【Developer applications】,这里我们可以看到当前账户所拥有的第三方应用
![QQ图片20150813170602.png](http://assets.tianmaying.com/md-image/49de37c7578ad4f5d3f702507ab3f028.png)
点击右上角的【Register new application】,按要求填写信息就可以申请一个第三方应用的身份。由于我们是本地调试,我们按照本地的测试地址填写相关信息即可:
注意右上角的Client Id和Client Secret,这两个信息是用来标识第三方应用身份的相关信息,特别注意Client Secret,Client Secret是用来和授权服务器交换验证凭证(Access Token)的,千万不能暴露出去。
这样,我们就拥有了第三方应用的身份,可以喝Github交互进行OAuth2的授权了。
功能分析
我们的第三方登陆功能实际上就是将OAuth2的授权流程跑通,最后将拿到的用户信息存储在数据库中,并将其与本地数据的用户信息对应起来,以便于下次登陆时直接拿到本地的用户信息。
总结一下OAuth的基本流程,实际上主要涉及到下列URL:
- 引导用户进行授权的授权地址(authorizationUrl)
- 用户授权后传递用户凭证(code)的redirectURL
- 使用用户凭证(code)交换验证凭证(Access Token)的地址
- 获取用户信息的地址(可能不止一个)
通过确认以上地址,我们可以搭建一个通用化的OAuth授权以及验证功能,不同的授权服务器只需要提供不同的地址,其他流程完全可以用相同的代码来解决,这就是我们今天需要实现的通用化第三方登陆功能的基础。
以Github为例,其相应的API地址分别为:
- https://github.com/login/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&state={state}
- http://localhost:8080/oauth/github/callback?code={code}&state={state}
- https://github.com/login/oauth/access_token
- https://api.github.com/user?access_token={access_token}
现在,我们需要做下列事情:
- 在首页显示Github的授权链接,使用户能够访问authorizationUrl
- 编写一个Controller,处理
http://localhost:8080/oauth/github/callback?code={code}&state={state}
的请求,主要是拿到code - 用code访问
http://localhost:8080/oauth/github/callback?code={code}&state={state}access token
- 然后利用access token访问
https://api.github.com/user?access_token={access_token}
,拿到用户信息 - 根据用户信息判断该用户是否已经绑定,如果已经绑定,直接登录,如果未绑定则导入到绑定页面,引导用户进入绑定页面
- 在绑定页面添加新的用户
引入依赖
工欲善其事必先利其器,让我们先将我们需要用到的工具添加进来:
<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用户与本地用户映射起来,是第三方登录的关键。
怎么样,第三方登录是不是很简单,快自己动手实现一下吧。