通过springboot框架,自己动手实现oauth2.0授权码模式认证

前言

     随着几大社交平台霸主地位的确立,各个小型网站越来越倾向于通过平台认证来简化申请和登录帐号的流程,以增加用户量。这种授权认证方式一般和oauth2.0协议脱不了关系。因为我是在接入qq登录时第一次看到这个标准,所以参考qq登录的官方模版来实现这一过程。(我之前已经写过一篇接入qq登录的博客:网站实现qq登录(springboot后台))

什么是oauth2.0

阅读理解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参数表示要求的授权范围(这里是只读)。

通过springboot框架,自己动手实现oauth2.0授权码模式认证_第1张图片

第二步,用户跳转后,B 网站会要求用户登录,然后询问是否同意给予 A 网站授权。用户表示同意,这时 B 网站就会跳回redirect_uri参数指定的网址。跳转时,会传回一个授权码,就像下面这样。


https://a.com/callback?code=AUTHORIZATION_CODE

上面 URL 中,code参数就是授权码。

通过springboot框架,自己动手实现oauth2.0授权码模式认证_第2张图片

第三步,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参数是令牌颁发后的回调网址。

通过springboot框架,自己动手实现oauth2.0授权码模式认证_第3张图片

第四步,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框架,自己动手实现oauth2.0授权码模式认证_第4张图片

开始自己实现

实现流程与代码:借鉴自:qq互联开发文档

首先新建两个模块。参考:SpringBoot多模块开发。推荐阅读:SpringBoot 多模块的优点与必要性

正如上面两个网站一样,我建立了两个模块,一个client服务器端口为8080,一个认证和资源服务器(我把认证服务器和资源服务器放到同一个服务器中了)将此服务器的端口设置为8888。(ip相同,端口不同,也算域名不同,也会有跨域问题)

其中授权服务器发放code(授权码)和access_token时,我是通过jwt来颁发的。参考我之前写的博客:认识和使用JWT

因为此部分代码和我的其他代码嵌合,所以暂时先不放到github中了。

参考流程图:

通过springboot框架,自己动手实现oauth2.0授权码模式认证_第5张图片(图片修改自:OAuth2.0协议草案V21的4.1节 )

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");

(A)用户访问客户端服务器,后者将前者导向授权服务器。

用户访问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";
    }

(B)用户选择是否给予客户端授权。

如果用户取消授权,关闭页面,不做任何处理。

(C)假设用户给予授权,授权服务器将用户导向客户端事先指定的"重定向URI"(redirection URI),同时附上一个授权码。

授权服务器验证提交表单的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;
        }

    }

(D)客户端服务器收到授权码,附上早先的"重定向URI",向授权服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。

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;
    }

(E)授权服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。

授权服务器验证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认证模式中的客户端模式,就是类似于这种:

通过springboot框架,自己动手实现oauth2.0授权码模式认证_第6张图片图片来自网络。

更新令牌:

令牌的有效期到了,如果让用户重新走一遍上面的流程,再申请一个新的令牌,很可能体验不好,而且也没有必要。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 网站验证通过以后,就会颁发新的令牌。

 

文章写得有点粗糙,先凑合着看,后期再修改。

 

 

 

 

你可能感兴趣的:(web项目)