随着几大社交平台霸主地位的确立,各个小型网站越来越倾向于通过平台认证来简化申请和登录帐号的流程,以增加用户量。这种授权认证方式一般和oauth2.0协议脱不了关系。因为我是在接入qq登录时第一次看到这个标准,所以参考qq登录的官方模版来实现这一过程。(我之前已经写过一篇接入qq登录的博客:网站实现qq登录(springboot后台))
阅读理解OAuth 2.0以及OAuth 2.0 的四种方式
注意oauth2.0有四种模式,而我只想实现其中的授权码模式,我从上面的两篇博客中抄袭出我需要的信息:
授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。
这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。
第一步,A 网站提供一个链接,用户点击后就会跳转到 B 网站,授权用户数据给 A 网站使用。下面就是 A 网站跳转 B 网站的一个示意链接。
https://b.com/oauth/authorize? response_type=code& client_id=CLIENT_ID& redirect_uri=CALLBACK_URL& scope=read
上面 URL 中,response_type
参数表示要求返回授权码(code
),client_id
参数让 B 知道是谁在请求,redirect_uri
参数是 B 接受或拒绝请求后的跳转网址,scope
参数表示要求的授权范围(这里是只读)。
第二步,用户跳转后,B 网站会要求用户登录,然后询问是否同意给予 A 网站授权。用户表示同意,这时 B 网站就会跳回redirect_uri
参数指定的网址。跳转时,会传回一个授权码,就像下面这样。
https://a.com/callback?code=AUTHORIZATION_CODE
上面 URL 中,code
参数就是授权码。
第三步,A 网站拿到授权码以后,就可以在后端,向 B 网站请求令牌。
https://b.com/oauth/token? client_id=CLIENT_ID& client_secret=CLIENT_SECRET& grant_type=authorization_code& code=AUTHORIZATION_CODE& redirect_uri=CALLBACK_URL
上面 URL 中,client_id
参数和client_secret
参数用来让 B 确认 A 的身份(client_secret
参数是保密的,因此只能在后端发请求),grant_type
参数的值是AUTHORIZATION_CODE
,表示采用的授权方式是授权码,code
参数是上一步拿到的授权码,redirect_uri
参数是令牌颁发后的回调网址。
第四步,B 网站收到请求以后,就会颁发令牌。具体做法是向redirect_uri
指定的网址,发送一段 JSON 数据。
{ "access_token":"ACCESS_TOKEN", "token_type":"bearer", "expires_in":2592000, "refresh_token":"REFRESH_TOKEN", "scope":"read", "uid":100101, "info":{...} }
上面 JSON 数据中,access_token
字段就是令牌,A 网站在后端拿到了。
首先新建两个模块。参考:SpringBoot多模块开发。推荐阅读:SpringBoot 多模块的优点与必要性
正如上面两个网站一样,我建立了两个模块,一个client服务器端口为8080,一个认证和资源服务器(我把认证服务器和资源服务器放到同一个服务器中了)将此服务器的端口设置为8888。(ip相同,端口不同,也算域名不同,也会有跨域问题)
其中授权服务器发放code(授权码)和access_token时,我是通过jwt来颁发的。参考我之前写的博客:认识和使用JWT
因为此部分代码和我的其他代码嵌合,所以暂时先不放到github中了。
参考流程图:
127.0.0.1:8080为客户端服务器
127.0.0.1:8888 为授权、资源服务器
准备:
授权服务器所在的环境中创建数据库,用来记录client服务器申请的appid和appsecret,用来做颁发access_token是的验证。
create table serve_client_info(
id char(11) primary key,
name varchar(50),
host_url varchar(256),
redirect_url varchar(256),
secret varchar(60)
)
insert into serve_client_info values("1234567","test","http://127.0.0.1:8080","/redirect","secret");
用户访问http://127.0.0.1:8080/login,客户端服务器生成state值将其保存到session中,然后将其重定向到授权服务器http://127.0.0.1:8888 /oauth2.0/authorize并携带参数:
参数 含义
response_type 授权类型,此值固定为“code”。
client_id 此clien服务器提前向授权服务器申请的client_id
redirect_uri 成功授权后的client服务器的回调地址,必须是注册时填写的主域名下的地址,
state client端的状态值。用于第三方应用防止CSRF攻击,成功授权后回调时会原样带回。请务必严格按照流程检查用户与state参数状态的绑定。
client服务器代码:
注意这里是将state放到了session中,步骤c中会讲明他的作用。
@GetMapping("/login")
public String clientLogin(HttpServletRequest request){
//获取当前sesion
HttpSession sessoin=request.getSession();
//随机产生字符串
String state= String.valueOf(System.currentTimeMillis());
log.info("进入/login接口,产生随机字符串:"+state);
sessoin.setAttribute("state",state);
//重定向
return "redirect:"+oauthHost+"/oauth2.0/authorize?response_type=code&client_id="
+ clientId + "&redirect_uri=" + myHost+"/redirect" + "&state=" + state;
}
授权服务器会返回一个登陆页面,其中有一个包含state值的隐藏表单,用来防止csrf。
授权服务器端代码:页面代码就不提供了,将许多数据放到session中,因为会需要验证。
//验证type和id,保存state到session中,然后返回登录页面,登录页面需要有一个携带state的隐藏表单
//如果不合法则返回客户端错误的页面
@GetMapping("/oauth2.0/authorize")
String loginPage(@RequestParam String response_type,
@RequestParam String client_id,
@RequestParam String redirect_uri,
@RequestParam String state,
@CookieValue("JSESSIONID") String sessionId,
ModelAndView modelAndView,
HttpSession session){
try{
if(!response_type.equals("code"))
throw new Exception(new Throwable("非法的response_type"));
//如果此url和之前登记的url不相同
oauthServeService.checkReUrl(client_id,redirect_uri);
log.info("进入认证登录页面,接收到的state值"+state);
modelAndView.addObject("state",state);
modelAndView.addObject("session",session);
//将cookie和url保存到session中
session.setAttribute("state",state);
session.setAttribute("appID",client_id);
session.setAttribute("sessionId",sessionId);
session.setAttribute("redirect_uri",redirect_uri);
}
catch (Exception e){
log.warn("进入登录页面失败:"+e.getCause().getMessage());
modelAndView.addObject("error",e.getCause().getMessage()+",请重新操作");
return "error";
}
return "login";
}
如果用户取消授权,关闭页面,不做任何处理。
授权服务器验证提交表单的state值与帐号和密码。
如果登录成功则授权服务器携带之前client服务器传来的cookie跳转到客户端服务器提供的回调地址http://127.0.0.1:8080/redirect,同时携带参数:
Code 授权码
State 之前传递来的state值。
如果登录失败则返回带有登录错误信息的登录页面。
授权服务器端代码:
/login接口是之前授权服务器返回的登录页面点击登录时访问的接口。
授权服务器跳转到client服务器时,会携带上之前client服务器跳转到授权服务器时的cookie,这样client服务器就可以从session找那个获取到之前保存到session中的state值。
//验证登录的帐号和密码,并将失败或者成功的结果传递给redirect_url
@PostMapping("/login")
Object login(@RequestParam String state,
@RequestParam String account,
@RequestParam String password,
HttpSession session,
HttpServletResponse httpServletResponse){
try {
log.info("验证登录,state值:"+state+":"+account+":"+password);
//验证state
if(!state.equals(session.getAttribute("state")))
throw new Exception(new Throwable("state不匹配,请关闭此页面重新进入"));
//从ldap验证登录,并获取id
String userid= allInfoService.login(account,password);
if(userid==null){
throw new Exception(new Throwable("账户或者密码不匹配"));
}
log.debug("找到用户");
//通过jwt生成授权码,返回给redirect_url
Cookie cookie = new Cookie("JSESSIONID", (String)session.getAttribute("sessionId"));
httpServletResponse.addCookie(cookie);
httpServletResponse.addHeader("Pragma","no-cache");
String url="redirect:"+session.getAttribute("redirect_uri")+"?authorization_code="+
JwtHelper.genCode(userid,(String) session.getAttribute("appID")) +"&state="+state;
log.debug(url);
return url;
}
catch (Exception e){
log.warn("用户帐号登录失败"+e.getCause().getMessage());
ModelAndView modelAndView=new ModelAndView();
modelAndView.addObject("error","登录失败:"+e.getCause().getMessage());
modelAndView.setViewName("login");
return modelAndView;
}
}
Client服务器get请求https://127.0.0.1:8888/oauth2.0/token携带参数:
grant_type 授权类型,在本步骤中,此值为“authorization_code”。
client_id 此clien服务器提前向授权服务器申请的client_id
client_secret 授权服务器给的秘钥
code 之前获得的授权码authorization code。注意此code会在10分钟内过期。
redirect_uri 与上面一步中传入的redirect_uri保持一致。
client服务器代码:需要使用fastjson库。
代码这么多是因为其中包含了获取到了access_token后,携带此token访问资源服务器来获取资源。
//回调页面,返回给webview accesstoken
@GetMapping("/redirect")
public Object redirect(@RequestParam String authorization_code,
@RequestParam String state,
HttpSession session,
ModelAndView modelAndView){
log.info("进入/redirect回调接口,接受state:"+state+"获取授权码:"+authorization_code);
//首先验证state是否相同
if(!state.equals(session.getAttribute("state"))){
log.warn("state值不相同,服务器中的state值:"+session.getAttribute("state"));
modelAndView.addObject("error","state不同");
modelAndView.setViewName("error");
return modelAndView;
}
String access_token,refresh_token;
try{
String result=WebConnect.getRequest(String.format("%s/oauth2.0/token?grant_type=authorization_code" +
"&client_id=%s&client_secret=%s&code=%s&redirect_uri=%s/redirect", oauthHost,clientId,clientSecret,authorization_code,myHost),null);
JSONObject jsonObject=JSONObject.parseObject(result);
//通过正则表达式匹配
access_token=jsonObject.getString("access_token");
refresh_token=jsonObject.getString("refresh_token");
}catch (Exception e){
modelAndView.addObject("error","获取access_token失败");
modelAndView.setViewName("error");
return modelAndView;
}
Map map=new HashMap<>();
String result;
try{
//通过access_token获取用户信息,并和refresh_token保存到数据库中
map.put("token",access_token);
result=WebConnect.getRequest(oauthHost+"/oauth2.0/me",map);
}catch (Exception e){
modelAndView.addObject("error","获取信息失败");
modelAndView.setViewName("error");
return modelAndView;
}
//通过access_token、用户id、clientID来获取用户信息
JSONObject jsonObject1 = null;
try {
map.clear();
map.put("token",access_token);
map.put("appID",clientId);
map.put("userID",result);
result=WebConnect.getRequest(oauthHost+"/user",map);
jsonObject1=JSONObject.parseObject(result);
}catch (Exception e){
modelAndView.addObject("error",jsonObject1.get("message"));
modelAndView.setViewName("error");
return modelAndView;
}
modelAndView.addObject("data",result);
modelAndView.addObject("token", JwtHelper.genToken(jsonObject1.getString("myid"),access_token));
modelAndView.setViewName("loginsuccess");
return modelAndView;
}
授权服务器验证code(授权码)和client服务器申请到的appid和appsecret,成功后返回json数据:
{
"access_token":"***",
"expires_in":"***",
"refresh_token":"***"
}
验证失败则直接返回错误信息。
授权服务器代码:
//颁布access_token
@GetMapping("oauth2.0/token")
@ResponseBody
Object getAccessToken(@RequestParam String grant_type,
@RequestParam String client_id,
@RequestParam String client_secret,
@RequestParam String code,
@RequestParam String redirect_uri) throws Exception {
log.info("获取access_token:clientid"+client_id+"client_secret:"+client_secret+"code:"+code);
JSONObject jsonObject=new JSONObject();
if(!grant_type.equals("authorization_code"))
throw new Exception("1001",new Throwable(Utiles.exceptionMap.get("1001")));
//如果此url和之前登记的url不相同
//会抛出异常
oauthServeService.checkPwdAndUrl(client_id,client_secret,redirect_uri);
//这个也会抛出异常
String userid=JwtHelper.verifyCodeAndRefreshToken(code,client_id);
long time=System.currentTimeMillis()+LONG_TIME2;
String refresh_token= JwtHelper.genRefreshToken(userid,client_id);
jsonObject.put("access_token",JwtHelper.genAccessToken(userid,time,client_id));
jsonObject.put("expires_in",time);
jsonObject.put("refresh_token",refresh_token);
return jsonObject;
}
接下来然后就是客户端服务器使用accessToken来向资源服务器获取用户资源 ,见d中提供的代码示例。
token发放规则:
分析qq开发文档,其中有一个通过code(授权码)来获取access_token,然后通过access_token来获取用户id。所以这一步可以很明显的看到code和access_token都中保存了用户id信息。因为refresh_token可以获取access_token所以refresh_token也要包含用户id信息。
为了加上区别性,我又在jwt中保存了appid信息。
授权服务器token颁发代码:需要使用jwt库。
public class JwtHelper {
private static Logger log= LoggerFactory.getLogger(JwtHelper.class);
private static final String SECRET = "session_secret";
private static final long SHORT_TIME = 10*60*1000;
private static final String ISSUER = "qihe";
//获取code的方法
public static String genCode(String userID,String appID){
//使用该加密算法
Algorithm algorithm = Algorithm.HMAC256(SECRET);
JWTCreator.Builder builder = JWT.create()
.withIssuer(ISSUER) //设置发布者
.withExpiresAt(new Date(System.currentTimeMillis()+SHORT_TIME))
.withClaim("userID",userID)
.withClaim("appID",appID);
return builder.sign(algorithm);
}
//获取access_token的方法
public static String genAccessToken(String userID,long time,String appID){
//使用该加密算法
Algorithm algorithm = Algorithm.HMAC256(SECRET);
//Builder是JWTCreator的静态内部类
//{静态内部类只能访问外部类的静态变量和静态方法,Outer.Inner inner = new Outer.Inner()}
JWTCreator.Builder builder = JWT.create()
.withIssuer(ISSUER) //设置发布者
.withExpiresAt(new Date(time))
.withClaim("userID",userID)
.withClaim("appID",appID);
log.debug("生成access_token通过:userid:"+userID+",appid:"+appID);
return builder.sign(algorithm); //使用上面的加密算法进行签名,返回String,就是token
}
//验证token方法
public static void verifyAccessToken(String token,String appID,String userID){
Algorithm algorithm = null;
algorithm = Algorithm.HMAC256(SECRET);
log.debug("jwt验证access_token通过userid"+userID+"appid:"+appID);
JWTVerifier verifier = JWT.require(algorithm).withIssuer(ISSUER) .withClaim("userID",userID).withClaim("appID",appID).build();
verifier.verify(token);
}
//验证token方法
public static String verifyAndGetAccessToken(String token){
Algorithm algorithm = null;
algorithm = Algorithm.HMAC256(SECRET);
JWTVerifier verifier = JWT.require(algorithm).withIssuer(ISSUER) .build();
DecodedJWT jwt = verifier.verify(token);
Map map = jwt.getClaims();
return map.get("userID").asString();
}
public static String genRefreshToken(String userID,String appID){
//使用该加密算法
Algorithm algorithm = Algorithm.HMAC256(SECRET);
JWTCreator.Builder builder = JWT.create()
.withIssuer(ISSUER) //设置发布者
.withClaim("userID",userID)
.withClaim("appID",appID);
return builder.sign(algorithm);
}
public static String verifyCodeAndRefreshToken(String token,String appID){
Algorithm algorithm = null;
algorithm = Algorithm.HMAC256(SECRET);
JWTVerifier verifier = JWT.require(algorithm).withIssuer(ISSUER).withClaim("appID",appID).build();
DecodedJWT jwt = verifier.verify(token);
Map map = jwt.getClaims();
return map.get("userID").asString();
}
}
关于后序client服务器请求资源服务器时,要求在请求头中带上appid和用户id以及access_token。
其实各个前端(andorid端,windows,mac等)都可以应用授权码模式,因为各个前端技术上都可以通过webview来打开第三方提供的授权页面。比如android App点击登录会弹出一个webview来打开网页,登录完成后,再关闭webview。但是这样会不太合适。比如对于android端,首选的还是oauth2.0认证模式中的客户端模式,就是类似于这种:
更新令牌:
令牌的有效期到了,如果让用户重新走一遍上面的流程,再申请一个新的令牌,很可能体验不好,而且也没有必要。OAuth 2.0 允许用户自动更新令牌。
具体方法是,B 网站颁发令牌的时候,一次性颁发两个令牌,一个用于获取数据,另一个用于获取新的令牌(refresh token 字段)。令牌到期前,用户使用 refresh token 发一个请求,去更新令牌。
https://b.com/oauth/token? grant_type=refresh_token& client_id=CLIENT_ID& client_secret=CLIENT_SECRET& refresh_token=REFRESH_TOKEN
上面 URL 中,grant_type
参数为refresh_token
表示要求更新令牌,client_id
参数和client_secret
参数用于确认身份,refresh_token
参数就是用于更新令牌的令牌。
B 网站验证通过以后,就会颁发新的令牌。
文章写得有点粗糙,先凑合着看,后期再修改。