OAuth2.0从入门到实战!
理论OAuth是一个关于授权authorization的开放网络标准用来授权第三方应用获取用户数据是目前最流行的授权机制它当前的版本是2.0。应用场景假如你正在“网站A”上冲浪看到一篇帖子表示非常喜欢当你情不自禁的想要点赞时它会提示你进行登录操作。打开登录页面你会发现除了最简单的账户密码登录外还为我们提供了微博、微信、QQ等快捷登录方式。假设选择了快捷登录它会提示我们扫码或者输入账号密码进行登录。登录成功之后便会将QQ/微信的昵称和头像等信息回填到“网站A”中此时你就可以进行点赞操作了。名词定义在详细讲解oauth2之前我们先来了解一下它里边用到的名词定义吧Client客户端它本身不会存储用户快捷登录的账号和密码只是通过资源拥有者的授权去请求资源服务器的资源即例子中的网站AResource Owner资源拥有者通常是用户即例子中拥有QQ/微信账号的用户Authorization Server认证服务器可以提供身份认证和用户授权的服务器即给客户端颁发token和校验tokenResource Server资源服务器存储用户资源的服务器即例子中的QQ/微信存储的用户信息认证流程如图是oauth2官网的认证流程图我们来分析一下A客户端向资源拥有者发送授权申请B资源拥有者同意客户端的授权返回授权码C客户端使用授权码向认证服务器申请令牌tokenD认证服务器对客户端进行身份校验认证通过后发放令牌E客户端拿着认证服务器颁发的令牌去资源服务器请求资源F资源服务器校验令牌的有效性返回给客户端资源信息为了大家更好的理解阿Q特地画了一张图到这儿相信大家对理论知识已经掌握得差不多了接下来我们就进入实战训练吧。实战在正式开始搭建项目之前我们先来做一些准备工作要想使用oauth2的服务我们得先创建几张表。数据库oauth2相关的建表语句可以参考官方初始化sql也可以查看阿Q项目中的init.sql文件私信回复“oauth2”获取源码。至于表结构大家可以先大体了解下其中字段的含义在init.sql文件中阿Q已经做了说明。oauth_client_details存储客户端的配置信息操作该表的类主要是JdbcClientDetailsService.javaoauth_access_token存储生成的令牌信息操作该表的类主要是JdbcTokenStore.javaoauth_client_token在客户端系统中存储从服务端获取的令牌数据操作该表的类主要是JdbcClientDetailsService.javaoauth_code存储授权码信息与认证信息即只有grant_type为authorization_code时该表才会有数据操作该表的类主要是JdbcAuthorizationCodeServices.javaoauth_approvals存储用户的授权信息oauth_refresh_token存储刷新令牌的refresh_token如果客户端的grant_type不支持refresh_token那么不会用到这张表操作该表的类主要是JdbcTokenStore在oauth_client_details表中添加一条数据client_id:cheetah_one //客户端名称必须唯一 resource_ids:product_api //客户端所能访问的资源id集合,多个资源时用逗号(,)分隔 client_secret:$2a$10$h/TmLPvXozJJHXDyJEN22ensJgaciomfpOc9js9OonwWIdAnRQeoi //客户端的访问密码 scope:read,write //客户端申请的权限范围,可选值包括read,write,trust。若有多个权限范围用逗号(,)分隔 authorized_grant_types:client_credentials,implicit,authorization_code,refresh_token,password //指定客户端支持的grant_type,可选值包括authorization_code,password,refresh_token,implicit,client_credentials, 若支持多个grant_type用逗号(,)分隔 web_server_redirect_uri:http://www.baidu.com //客户端的重定向URI,可为空, 当grant_type为authorization_code或implicit时, 在Oauth的流程中会使用并检查与注册时填写的redirect_uri是否一致 access_token_validity:43200 //设定客户端的access_token的有效时间值(单位:秒),可选, 若不设定值则使用默认的有效时间值(60 * 60 * 12, 12小时) autoapprove:false //设置用户是否自动Approval操作, 默认值为 false, 可选值包括 true,false, read,write数据库中对密码进行了加密处理大家可以在此路径下自行生成用户角色相关的表也在init.sql文件中表结构非常简单大家自行查阅。我的初始化数据为依赖引入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-security/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-oauth2/artifactId /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-jwt/artifactId /dependency至于其它依赖大家可以根据需要自行引入不再赘述回复“oauth2”获取源码。资源服务配置文件对服务端口、应用名称、数据库、mybatis和日志进行了配置。写了一个简单的控制层代码用来模拟资源访问RestController RequestMapping(/product) public class ProductController { GetMapping(/findAll) public String findAll(){ return 产品列表查询成功; } }接着创建配置类继承ResourceServerConfigurerAdapter并增加EnableResourceServer注解开启资源服务重写两个configure方法/** * 指定token的持久化策略 * InMemoryTokenStore 表示将token存储在内存中 * RedisTokenStore 表示将token存储在redis中 * JdbcTokenStore 表示将token存储在数据库中 * return */ Bean public TokenStore jdbcTokenStore(){ return new JdbcTokenStore(dataSource); } /** * 指定当前资源的id和token的存储策略 * param resources * throws Exception */ Override public void configure(ResourceServerSecurityConfigurer resources) throws Exception { //此处的id可以写在配置文件中这里我们先写死 resources.resourceId(product_api).tokenStore(jdbcTokenStore()); } /** * 设置请求权限和header处理 * param http * throws Exception */ Override public void configure(HttpSecurity http) throws Exception { //固定写法 http.authorizeRequests() //指定不同请求方式访问资源所需的权限一般查询是read其余都是write .antMatchers(HttpMethod.GET,/**).access(#oauth2.hasScope(read)) .antMatchers(HttpMethod.POST,/**).access(#oauth2.hasScope(write)) .antMatchers(HttpMethod.PATCH,/**).access(#oauth2.hasScope(write)) .antMatchers(HttpMethod.PUT,/**).access(#oauth2.hasScope(write)) .antMatchers(HttpMethod.DELETE,/**).access(#oauth2.hasScope(write)) .and() .headers().addHeaderWriter((request,response) - { //域名不同或者子域名不一样并且是ajax请求就会出现跨域问题 //允许跨域 response.addHeader(Access-Control-Allow-Origin,*); //跨域中会出现预检请求如果不能通过则真正请求也不会发出 //如果是跨域的预检请求则原封不动向下传递请求头信息否则预检请求会丢失请求头信息主要是token信息 if(request.getMethod().equals(OPTIONS)){ response.setHeader(Access-Control-Allow-Methods,request.getHeader(Access-Control-Allow-Methods)); response.setHeader(Access-Control-Allow-Headers,request.getHeader(Access-Control-Allow-Headers)); } }); }当然我们也可以配置忽略校验的url在上边的public void configure(HttpSecurity http) throws Exception中进行配置ExpressionUrlAuthorizationConfigurerHttpSecurity .ExpressionInterceptUrlRegistry config http.requestMatchers().anyRequest() .and() .authorizeRequests(); properties.getUrls().forEach(e - { config.antMatchers(e).permitAll(); });因为我们是需要进行校验的所以我把对应的代码给注释掉了大家可以回复“oauth2”下载源码自行查看。然后将实现了UserDetails的SysUser和实现了GrantedAuthority的SysRole放到项目中当请求发过来时oauth2会帮我们自行校验。认证服务配置文件对服务端口、应用名称、数据库、mybatis和日志进行了配置。Security配置还是和之前SecurityJWT组合拳的配置大同小异不了解的可以先看下该文。①将继承了UserDetailsService的ISysUserService的实现类SysUserServiceImpl重写loadUserByUsername方法Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return this.baseMapper.selectOne(new LambdaQueryWrapperSysUser().eq(SysUser::getUsername, username)); }②继承WebSecurityConfigurerAdapter类增加EnableWebSecurity注解并重写方法/** * 指定认证对象的来源和加密方式 * param auth * throws Exception */ Override public void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userService).passwordEncoder(passwordEncoder()); } /** * 安全拦截机制最重要 * param httpSecurity * throws Exception */ Override public void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity //CSRF禁用因为不使用session .csrf().disable() .authorizeRequests() //登录接口和静态资源不需要认证 .antMatchers(/login*,/css/*).permitAll() //除上面的所有请求全部需要认证通过才能访问 .anyRequest().authenticated() //返回HttpSecurity以进行进一步的自定义,证明是一次新的配置的开始 .and() .formLogin() //如果未指定此页面则会跳转到默认页面 // .loginPage(/login.html) .loginProcessingUrl(/login) .permitAll() //认证失败处理类 .failureHandler(customAuthenticationFailureHandler); } /** * AuthenticationManager 对象在OAuth2.0认证服务中要使用提前放入IOC容器中 * return * throws Exception */ Override Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); }AuthorizationServer配置①继承AuthorizationServerConfigurerAdapter类增加EnableAuthorizationServer注解开启认证服务②依赖注入注入7个实例Bean对象/** * 数据库连接池对象 */ private final DataSource dataSource; /** * 认证业务对象 */ private final ISysUserService userService; /** * 授权码模式专用对象 */ private final AuthenticationManager authenticationManager; /** * 客户端信息来源 * return */ Bean public JdbcClientDetailsService jdbcClientDetailsService(){ return new JdbcClientDetailsService(dataSource); } /** * token保存策略 * return */ Bean public TokenStore tokenStore(){ return new JdbcTokenStore(dataSource); } /** * 授权信息保存策略 * return */ Bean public ApprovalStore approvalStore(){ return new JdbcApprovalStore(dataSource); } /** * 授权码模式数据来源 * return */ Bean public AuthorizationCodeServices authorizationCodeServices(){ return new JdbcAuthorizationCodeServices(dataSource); }③重写方法进行配置/** * 用来配置客户端详情服务ClientDetailsService * 客户端详情信息在这里进行初始化 * 指定客户端信息的数据库来源 * param clients * throws Exception */ Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.withClientDetails(jdbcClientDetailsService()); } /** * 检测 token 的策略 * param security * throws Exception */ Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security //允许客户端以form表单的方式将token传达给我们 .allowFormAuthenticationForClients() //检验token必须需要认证 .checkTokenAccess(isAuthenticated()); } /** * OAuth2.0的主配置信息 * param endpoints * throws Exception */ Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints //刷新token时会验证当前用户是否已经通过认证 .userDetailsService(userService) .approvalStore(approvalStore()) .authenticationManager(authenticationManager) .authorizationCodeServices(authorizationCodeServices()) .tokenStore(tokenStore()); }其它关于用户表和权限表的代码可参考源码回复“oauth2”获取源码。模式授权码模式我们前边所讲的内容都是基于授权码模式授权码模式被称为最安全的一种模式它获取令牌的操作是在两个服务端进行的极大的减小了令牌泄漏的风险。启动两个服务当我们再次请求127.0.0.1:9002/product/findAll接口时会提示以下错误{ error: unauthorized, error_description: Full authentication is required to access this resource }①调用接口获取授权码发送127.0.0.1:9001/oauth/authorize?response_typecodeclient_idcheetah_one请求前边的路径是固定形式的response_typecode表示获取授权码client_idcheetah_one表示客户端的名称是我们数据库配置的数据。该页面是oauth2的默认页面输入用户的账户密码点击登录会提示我们进行授权这是数据库oauth_client_details表我们设置autoapprove为false起到的效果。选择Approve点击Authorize按钮会发现我们设置的回调地址oauth_client_details表中的web_server_redirect_uri后边拼接了code值该值就是授权码。查看数据库发现oauth_approvals和oauth_code表已经存入数据了。拿着授权码去获取token获取到token之后oauth_access_token和oauth_refresh_token表中会存入数据以用于后边的认证。而oauth_code表中的数据被清除了这是因为code值是直接暴漏在网页链接上的oauth2为了防止他人拿到code非法请求而特意设置为仅用一次。拿着获取到的token去请求资源服务的接口此时有两种请求方式接下来我们再来看一下oauth2的其它模式。简化模式所谓简化模式是针对授权码模式进行的简化它将授权码模式中获取授权码的步骤省略了直接去请求获取token。流程发送请求127.0.0.1:9001/oauth/authorize?response_typetokenclient_idcheetah_one跳转到登录页进行登录response_typetoken表示获取token。输入账号密码登录之后会直接在浏览器返回token我们就可以像授权码方式一样携带token去请求资源了。该模式的弊端就是token直接暴漏在浏览器中非常不安全不建议使用。密码模式密码模式下用户需要将账户和密码提供给客户端向认证服务器申请令牌所以该种模式需要用户高度信任客户端。流程请求如下获取成功之后可以去访问资源了。客户端模式客户端模式已经不太属于oauth2的范畴了用户直接在客户端进行注册然后客户端去认证服务器获取令牌时不需要携带用户信息完全脱离了用户也就不存在授权问题了。发送请求如下获取成功之后可以去访问资源了。刷新token权限校验除了我们在数据库中为客户端配置资源服务外我们还可以动态的给用户分配接口的权限。①开启Security内置的动态配置在开启资源服务时给ResourceServerConfig类增加注解EnableGlobalMethodSecurity(securedEnabled true,prePostEnabled true)②给接口增加权限GetMapping(/findAll) Secured(ROLE_PRODUCT) public String findAll(){ return 产品列表查询成功; }③在用户登录时设置用户权限Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser this.baseMapper.selectOne(new LambdaQueryWrapperSysUser().eq(SysUser::getUsername, username)); sysUser.setRoleList(AuthorityUtils.commaSeparatedStringToAuthorityList(ROLE_PRODUCT)); return sysUser; }然后测试会发现可以正常访问。采坑包名问题当我在创建项目的时候给product和server两个模块设置了不同的包名导致发送请求获取资源时报错。经过分析得知在登录账号时会将用户的信息存储到oauth_access_token表的authentication中在进行token校验时会根据token_id取出该字段进行反序列化如果此时发现包名不一致便会导致解析token失败因此请求资源失败。解决思路两个项目的包名改为一致可以将用户和权限的实体抽成单独的模块供其它模块引用loadUserByUsername方法中使用的用户实体类不需要继承UserDetailsService类每次返回时用user类包装一下即可数据库问题当我在进行权限校验测试时在设置权限时发现少打了一个单词导致请求一直出错。修改完成之后继续请求仍提示权限不足。于是我将数据库中oauth_refresh_token和oauth_access_token的数据清除重新开始测试就可以了。个人认为是生成token时发现数据库中token存在故不刷新token但进行校验时却用带有权限标识的token前去校验导致失败。至于其它的小坑在这不再赘述如果遇到问题建议按照流程对比我的源码仔细检查回复“oauth2”获取源码。小结本文从原理、应用场景、认证流程出发对oauth2进行了基本的讲解并且手把手带大家完成了项目的搭建。大家在对授权码模式、简化模式、密码模式、客户端模式进行测试的同时要将重点放到授权码模式上。