likes
comments
collection
share

让WebSocket支持Oauth2 TOKEN验证的尝试

作者站长头像
站长
· 阅读数 59

​ 很早的时候在下用一种比较简陋的方式利用WebSocket实现了在线Selenium执行功能《一》《二》。大概效果如下:

让WebSocket支持Oauth2 TOKEN验证的尝试

​ 其实实现原理挺粗暴的,服务端开启一个WebSocket服务端,一直处于监听状态。浏览器利用js脚本发起连接,并将内容通过WebSocket发送给服务端。只要发送的格式符合服务端的定义,理论上讲,服务端会无条件执行任何请求

让WebSocket支持Oauth2 TOKEN验证的尝试

​ 虽然过去了这么久,但是一想到服务器上还运行这么危险的服务,我便久久无法入眠(其实并没有)。趁着这次国庆大假,在家又折腾了一下我的在线代码执行服务,最主要的目标就是让websocket能更安全的运行。

​ 怎么才能更安全的运行呢?当然要利用小云上已运行的KeyCloak所提供的OAuth2认证啦。

OAuth2 Token

什么是OAuth2?

OAuth

允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的网站(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要分享他们的访问许可或他们数据的所有内容。

OAuth是OpenID的一个补充,但是完全不同的服务。

OAuth 2.0

是OAuth协议的下一版本,但不向后兼容OAuth 1.0。 OAuth 2.0关注客户端开发者的简易性,同时为Web应用,桌面应用和手机,和起居室设备提供专门的认证流程。2012年10月,OAuth 2.0协议正式发布为RFC 6749

什么是Token?

​ Token就是OAuth2服务器向用户发放的一个令牌,用户持有这个令牌就可以访问所有允许被访问的资源。令牌与密码的作用是一样的,都可以用于身份的校验,但是在OAuth2协议之下也有些差异。

  1. 令牌是短期的,到期会自动失效,用户自己无法修改。密码一般长期有效,用户不修改,就不会发生变化
  2. 令牌可以被数据所有者撤销,会立即失效。以上例而言,屋主可以随时取消快递员的令牌。密码一般不允许被他人撤销。
  3. 令牌有权限范围(scope),比如只能进小区的二号门。对于网络服务来说,只读令牌就比读写令牌更安全。密码一般是完整权限。

​ 正是由于令牌的灵活性,保证了其既可以让三方应用获得权限,又随时可控(范围、时间),不用担心像密码那样危及整个系统的安全。

什么是SSO?

单点登录(SingleSignOn,SSO),就是通过用户的一次性鉴别登录。当用户在身份认证服务器上登录一次以后,即可获得访问单点登录系统中其他关联系统和应用软件的权限,同时这种实现是不需要管理员对用户的登录状态或其他信息进行修改的,这意味着在多个应用系统中,用户只需一次登录就可以访问所有相互信任的应用系统。这种方式减少了由登录产生的时间消耗,辅助了用户管理,是比较流行的。

​ 结合上文,OAuth2提供Token不就是实现SSO最好的方式吗?

​ 基于之前在小云上搭建《适配KeyCloak的网管服务》,Beta小云已支持SSO登录。换言之,用浏览器打开我的在线执行地址,通过SSO,可以从KeyCloak获取到一个Token。有了这个Token,就意味着从当前会话中发出的请求都可以带上这个Token,如果Token有效,则运行继续访问,如果无效则拒绝当前的请求。

让WebSocket支持Oauth2 TOKEN验证的尝试

​ 那么WebSocket是否也可以呢?

Spring Security 如何验证Token?

​ 在《Spring极简资源服务器》中介绍了一种利用Spring Security快速构建资源服务器的方法,并且该资源服务器可验证访问请求中的Token是否合法有效。不过这只是解决了如何做(How),并没有解释为什么能这样(Why)。

​ 其实关于Spring Security如何进行OAuth2验证的文章,网上挺多的,我这里就简单说说吧。

​ Spring 对请求报文的处理大部分都是在Filter中进行的,我们在代码WebSecurityConfigurerAdapter中配置了资源管理策略,大部分的配置差不多都长这个样子:

protected void configure(HttpSecurity http) throws Exception{
        http.authorizeRequests(authz -> authz
                .antMatchers(HttpMethod.GET, "/xxx/").permitAll()
                .antMatchers(HttpMethod.GET, "/xxx/auth/**").hasRole(role_auth)
                .antMatchers(HttpMethod.GET, "/xxx/admin/**").hasRole(role_admin)
                .anyRequest().authenticated())
                .oauth2ResourceServer().jwt().jwtAuthenticationConverter(converter);
    }

​ 其实就是向Spring Filter Chain 添加了OAuth2校验Filter。当然这一步仅仅是让Spring框架知道,接收到请求报文后需要进行对应的处理而已,并不是真正验证的地方。真正进行验证的入口地方在

org.springframework.security.authentication.ProviderManager的authenticate函数。

​ 因为我的KeyCloak提供的是JWT Token,因此Spring框架会进一步将收到的Token交给

org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider的authenticate函数。

​ 之后经过一系列眼花缭乱的封装转换后,Spring框架将收到的Token转换为一个JWT对象,注意这个JWT对象中的数据均与请求中的数据保持一直,即并不判断其是否有效

服务端公钥比对

​ 终于来到重点操作部分了,com.nimbusds.jose.proc.JWSVerificationKeySelector的selectJWSKeys函数,这个函数只做一件事儿,从OAuth服务端获取JWT公钥信息。对于KeyCloak来说可以访问"/realms/Beta/protocol/openid-connect/certs"来获取。拿到JWT公钥信息之后,则会与之前转换的JWT对象进行对比(主要对比签名的公钥是否一致),如果比对成功则会进入下一步解析Token的操作。那么如果比对不成功呢?就会抛出异常:

throw new BadJOSEException("Signed JWT rejected: Another algorithm expected, or no matching key(s) found");

​ 毕竟比对了半天都找不到合适的公钥,这对Spring来说,也是相当懵逼的呢?不是吗?

让WebSocket支持Oauth2 TOKEN验证的尝试

解析&校验Token

​ 经历了上面的的操作后,Spring框架基本可以认为当前收到的Token,是未被篡改过的,如果有篡改,则无法匹配上OAuth2服务端的公钥。既然未篡改,那么接下来就需要确认下Token是否过期、Token是否拥相关的权限资格。

​ Spring Security框架会用到2个校验器对Token进行校验,分别是:

  • org.springframework.security.oauth2.jwt.JwtTimestampValidator
  • org.springframework.security.oauth2.jwt.JwtIssuerValidator

​ 首先会用到JwtTimestampValidator的validateh函数对过期时间进行判断。如果Token中的过期时间已在服务器时间之前,那么Spring会返回一个OAuth2Error的错误,内容大概是“你的JWT 过期时间是xxxx”。

​ 紧接着会用到JwtIssuerValidator的validate函数对权限进行判断。还记的我在《Spring极简资源服务器》中我自定义了一段权限解析器的代码吗?

JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new Converter<Jwt, Collection<GrantedAuthority>>() {
    @Override
    public Collection<GrantedAuthority> convert(Jwt source) {
        JSONArray roles = (JSONArray) ((JSONObject) (source.getClaims().get("realm_access"))).get("roles");
        //                JSONArray scopes = (JSONArray) ((JSONObject) (source.getClaims().get("resource_access"))).get("roles");
        ArrayList<GrantedAuthority> roleArray = new ArrayList<>();
        for(int i = 0; i < roles.size(); i++){
            roleArray.add(new Role(roles.get(i)));
        }
        return roleArray;
    }
});

​ 是的,没错!JwtIssuerValidator最终会调用到这段代码,以便按照我们预期的方式解析出Token中的权限(角色)信息,再和被请求访问的资源所要求的权限(角色)信息进行对比。

​ 好了,校验完成后,如果存在错误信息,则会抛出JwtValidationException异常,剩下的就交给Filter继续处理吧,该报401的报401,该报302的报302。

WebSocket 能否支持Token验证?

​ 遗憾的是,似乎WebSocket协议本身并不支持OAuth2协议,无法在头部中插入Authorization字段(可能有办法,只不过我还不知道罢了)。同时Spring的Security Filter也并不会处理ws/wss协议过来的数据。

​ 既然无法利用Spring Security自动完成Token的校验,那么唯一可行的办法就是让WebSocket将Token作为信息的一部分传送给服务端,我们就自己去OAuth2 服务端验证吧,大不了模仿Spring框架再做一套呗。

OAuth2 Server Endpoint

​ 如果你用过早期的Spring cloud OAuth2 Server,一定知道它提供了多个Endpoint,比如这些:

  • /oauth/authorize:获取授权码的端点
  • /oauth/token:获取令牌端点。
  • /oauth/confifirm_access:用户确认授权提交端点。
  • /oauth/error:授权服务错误信息端点。
  • /oauth/check_token:用于资源服务访问的令牌解析端点。
  • /oauth/token_key:提供JWT公有密匙端点。

​ 其中有一个端点很有意思,check_token,资源服务器接收到带Token的访问请求后,会将Token发回这个端点,并根据端点返回的结果判断,当前请求是否具有必要的访问权限。

​ 那么我可否先将WebSocket接收到的Token,送给OAuth2服务器做一次验证,如果验证通过,则表明当前请求是被授权了的呢?

​ 答案当然是可以。不过我的OAuth2服务器是KeyCloak,这个端点需要做些调整。

KeyCloak Endpoint

​ KeyCloak的端点有很多,通过访问

OAuth2ServerAddress/realms/OAuth2_Server_Address/realms/OAuth2ServerAddress/realms/OAuth2_Realm/.well-known/openid-configuration

可以查看到当前服务对外提供的所有端点(当然也包含很多其它的信息,感兴趣的小伙伴可以自己查查看):

{ "authorization_endpoint": "http://localhost:8080/realms/Test/protocol/openid-connect/auth", "token_endpoint": "http://localhost:8080/realms/Test/protocol/openid-connect/token", "introspection_endpoint": "http://localhost:8080/realms/Test/protocol/openid-connect/token/introspect", "userinfo_endpoint": "http://localhost:8080/realms/Test/protocol/openid-connect/userinfo", "end_session_endpoint": "http://localhost:8080/realms/Test/protocol/openid-connect/logout", ...... "registration_endpoint": "http://localhost:8080/realms/Test/clients-registrations/openid-connect", ...... "introspection_endpoint_auth_methods_supported": [ "private_key_jwt", "client_secret_basic", "client_secret_post", "tls_client_auth", "client_secret_jwt" ],introspection_endpoint

......

}

​ 这是在本地执行的一个KeyCloak环境上查询的结果,这当中有一个introspection_endpoint。该端点的作用可以用来校验Token是否有效。

​ 只需要以POST的方式向该Endpoint提交包含如下信息的表单即可进行Token的验证。

  1. username : 用户名
  2. client_id : Client ID
  3. client_secret : Client Credentials Secret Code
  4. token : 需要验证的Token

​ 无论Token是否有效,只要前面三项填写正确,端点都将返回200。而验证结果以JSON的格式在Respond Body中体现。

​ 如Token无效(错误或过期)时,返回如下图:

让WebSocket支持Oauth2 TOKEN验证的尝试

​ 如果Token有效,则返回如下图:

让WebSocket支持Oauth2 TOKEN验证的尝试

​ 返回结果中最关键的就是active字段,因此Token是否有效、合法,完全可通过该字段进行判断。

Java代码实现

​ 有了上面的判断,那么用代码实现就变成顺理成章的事儿了。代码如下:

public static Boolean verfiyOAuthToken(String url, String clientid, String clientsecret, String username, String token){
        Boolean ret = null;
        OutputStreamWriter out = null;
        BufferedReader in = null;
        StringBuilder result = new StringBuilder();
        try{
            URL introspectUrl = new URL(url);
            HttpURLConnection conn =(HttpURLConnection)introspectUrl.openConnection();
            conn.setDoOutput(true);
            conn.setDoInput(true);
            conn.setRequestMethod("POST");
            conn.setRequestProperty("accept", "*/*");
            conn.setRequestProperty("connection", "Keep-Alive");
            conn.setRequestProperty("user-agent",
            "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)");
            conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
            conn.connect();
            out = new OutputStreamWriter(conn.getOutputStream(), "UTF-8");
            String data = String.format("username=%s&client_id=%s&client_secret=%s&token=%s", username, clientid, clientsecret, token);
            out.write(data);的
            out.flush();
            in = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8"));
            String line;
            while ((line = in.readLine()) != null) {
                result.append(line);
            }
            System.out.println(String.format("verfiyOAuthToken: params(%s),", data, result.toString()));
            ret = JSONObject.parseObject(result.toString()).getBoolean("active");
        }catch(IOException e){
            e.printStackTrace();
        }finally{
            try{
                if(out != null){
                    out.close();
                }
                if(in != null){
                    in.close();
                }
            }catch(IOException e){
                e.printStackTrace();
            }
        }
        System.out.println("verfiyOAuthToken ret:" + ret);
        return ret;
    }

​ 至此,我们的服务程序就可以去校验WebSocket传送的Token,是否合法有效(这里,我做的比较简单,仅仅判断Token是否处于激活状态)。

相关代码已更新至Gitee

转载自:https://juejin.cn/post/7153217985807646750
评论
请登录