项目源码

基于cas实现的sso虽然实现比较简单,但功能实在是单一,性能(每个请求都有验证ticket),可靠性(必须保证认证服务器高可用)都得打个问号,而oauth2功能强大,整合spring security,jwt,能实现一套用户session管理,token刷新,权限控制,跨一级域名单点登录等,是一套强大的组合拳,oauth2也是第三方微信,QQ授权登录的核心。

一、基本原理-参考

同域单点登录原理

  1. 门户系统设置 Cookie 的 domain 为一级域名也就是 zlt.com,这样就可以共享门户的 Cookie 给所有的使用该域名(xxx.zlt.com)的系统
  2. 使用 Spring Session 等技术让所有系统共享 Session
  3. 这样只要门户系统登录之后无论跳转应用1或者应用2,都能通过门户 Cookie 中的 sessionId 读取到 Session 中的登录信息实现单点登录

Oauth2跨域单点登录

  1. 访问系统1判断未登录,则跳转到UAA系统请求授权
  2. UAA系统域名 sso.com 下的登录地址中输入用户名/密码完成登录
  3. 登录成功后UAA系统把登录信息保存到 Session 中,并在浏览器写入域为 sso.com 的 Cookie
  4. 访问系统2判断未登录,则跳转到UAA系统请求授权
  5. 由于是跳转到UAA系统的域名 sso.com 下,所以能通过浏览器中UAA的 Cookie 读取到 Session 中之前的登录信息完成单点登录

二、认证服务器雏形

1.依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.2.1.RELEASE</version><relativePath/> </parent><groupId>com.jwolf</groupId><artifactId>oauth2sso-server</artifactId><version>0.0.1-SNAPSHOT</version><name>oauth2sso-server</name><description>Demo project for Spring Boot</description><properties><java.version>1.8</java.version><spring-cloud.version>Greenwich.SR6</spring-cloud.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-oauth2</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies><dependencyManagement><dependencies><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-dependencies</artifactId><version>${spring-cloud.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>

2.spring security相关配置

@Service
public class UserService implements UserDetailsService {private List<User> userList=new ArrayList<>(8);@Autowiredprivate PasswordEncoder passwordEncoder;/*** 这里为了方便这里就不查数据库了*/@PostConstructpublic void initData() {userList.add(new User("user1",passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("super")));userList.add(new User("user2",passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("admin")));userList.add(new User("user3",passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("common")));}@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {Optional<User> optionalUser = userList.stream().filter(item -> item.getUsername().equals(username)).findAny();if (optionalUser==null) {throw new UsernameNotFoundException("用户名或密码错误");}//这里把查到的用户返回就可以了,spring security内部会进行密码比对return optionalUser.get();}
}

神坑预警:这里的用户列表要么从DB查,要么写的方法内每次重新创建,否则会出现调用localhost:9402/logout或清除cookie后再次登录报警告“encoded password  empty。。”导致无法再次登录,重启认证服务器才能登录

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate UserDetailsService userDetailsService;@Autowiredprivate PasswordEncoder passwordEncoder;@Overrideprotected void configure(HttpSecurity http) throws Exception {http.formLogin().and().authorizeRequests().anyRequest().authenticated();}@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);}
}

3.oauth2相关配置

@Configuration
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception {clients.inMemory().withClient("client1").secret(passwordEncoder().encode("secret1")).authorizedGrantTypes("authorization_code").scopes("all")//.accessTokenValiditySeconds(10)//.refreshTokenValiditySeconds(864000).redirectUris("http://baidu.com","http://localhost:9502/login") // 授权成功后运行跳转的url,sso客户端默认/login,可在client端通过security.oauth2.sso.login-path修改为其它.autoApprove(false)  // true则自动授权,跳过授权页面点击步骤.and().withClient("client2").secret(passwordEncoder().encode("secret2")).authorizedGrantTypes("authorization_code").scopes("all")//.accessTokenValiditySeconds(10)//.refreshTokenValiditySeconds(864000).redirectUris("http://localhost:9602/login").autoApprove(false);}@Overridepublic void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {endpoints.tokenStore(jwtTokenStore()).accessTokenConverter(jwtAccessTokenConverter());}@Overridepublic void configure(AuthorizationServerSecurityConfigurer security) throws Exception {// 要访问认证服务器tokenKey的时候需要经过身份认证security.tokenKeyAccess("isAuthenticated()");}/*** 可以使用jdbc,redis,jwt,memory等方式存储token* @return*/@Beanpublic TokenStore jwtTokenStore() {return new JwtTokenStore(jwtAccessTokenConverter());}@Beanpublic JwtAccessTokenConverter jwtAccessTokenConverter() {JwtAccessTokenConverter converter = new JwtAccessTokenConverter();// JWT签名用的key,泄漏会导致JWT被伪造converter.setSigningKey("dev");return converter;}@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}}

4.application.yml及启动类

server:port: 9402
spring:application:name: oauth2-server
/*** <p>* Description: 认证服务器* </p>** @author majun* @version 1.0* @date 2020-10-28 20:59*/
@SpringBootApplication
@EnableAuthorizationServer
@EnableWebSecurity
public class Oauth2ssoApplication {public static void main(String[] args) {SpringApplication.run(Oauth2ssoApplication.class, args);}
}

三、SSO client端(ssclient1,client2)

1.依赖同认证服务器,可增加一个解析JWT的工具包

 <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency>

2.测试接口 ,注:用户信息,jwt,权限可以从SecurityContextHolder.getContext()获取

@RestController
@RequestMapping("/user")
public class UserController {/*** 测试接口-获取用户信息* @param authentication* @return*/@GetMapping("/getCurrentUser")//@PreAuthorize("hasAuthority('super')")//@PreAuthorize("hasAnyRole('ROLE_NORMAL')")//@PreAuthorize("hasPermission('')")public Object getCurrentUser(Authentication authentication) {//解析看看JWT内容OAuth2AuthenticationDetails details =(OAuth2AuthenticationDetails) authentication.getDetails();Claims claims = Jwts.parser().setSigningKey("dev".getBytes(StandardCharsets.UTF_8)).parseClaimsJws(details.getTokenValue()).getBody();System.out.println(claims);return authentication;}
}

3. client1,client2的application.yml ,启动类 @EnableOAuth2Sso需要开启SSO

server:port: 9502servlet:session:cookie:# 防止cookie冲突,冲突会导致登录验证不通过,如果不同client端使用相同的cookie名会导致挤掉另一client授权的cookie被覆盖name: OAUTH2-CLIENT1-SESSIONIDspring:application:name: oauth2-client1oauth2-service-url: http://localhost:9402security:oauth2:client:client-id: client1 client-secret: secret1user-authorization-uri: ${oauth2-service-url}/oauth/authorize access-token-uri: ${oauth2-service-url}/oauth/tokenresource:jwt:key-uri: ${oauth2-service-url}/oauth/token_keykey-value: dev #对应认证服务器jwt key的签名
server:port: 9502servlet:session:cookie:# 防止cookie冲突,冲突会导致登录验证不通过,如果不同client端使用相同的cookie名会导致挤掉另一client授权的cookie被覆盖name: OAUTH2-CLIENT1-SESSIONIDspring:application:name: oauth2-client2oauth2-service-url: http://localhost:9402security:oauth2:client:client-id: client1 client-secret: secret1user-authorization-uri: ${oauth2-service-url}/oauth/authorize access-token-uri: ${oauth2-service-url}/oauth/tokenresource:jwt:key-uri: ${oauth2-service-url}/oauth/token_keykey-value: dev #对应认证服务器jwt key的签名

四、测试

访问client2业务接口:http://localhost:9602/user/getCurrentUser

因为没登录重定向到认证服务器去登录,user1/123456

点击授权后跳回client2的业务接口

然后访问client1业务接口:http://localhost:9502/user/getCurrentUser,因为client2已登录,client1就不用再登录了,直接跳到授权页面,点击同意授权直接跳回业务接口

五、系统优化及进阶使用

1.页面美化—认证服务器新增自定义登录页面mylogin.html,自定义认证页面mygrant.html及一个控制视图跳转的CustomAuthController,然后SecurityConfig修改相关配置即可。走起>>>

新增thymeleaf依赖及配置

 <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency>
spring:thymeleaf:prefix: classpath:/templates/suffix: .htmlcache: false

自定义登录页与授权页面

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>登录</title>
</head><body>
<div class="login-container"><div class="form-container"><p class="title">用户登录</p><form name="loginForm" method="post"  action="/authentication/form" ><input type="text" name="username" placeholder="用户名"/><br><input type="password" name="password" placeholder="密码"/><br><button type="submit" class="btn">登 &nbsp;&nbsp; 录</button></form><p style="color: red" th:if="${param.error}">用户名或密码错误</p></div>
</div>
</body>
<style>.login-container {margin: 50px;width: 100%;}.form-container {margin: 0px auto;width: 50%;text-align: center;box-shadow: 1px 1px 10px #888888;height: 300px;padding: 5px;}input {margin-top: 10px;width: 350px;height: 30px;border-radius: 3px;border: 1px #E9686B solid;padding-left: 2px;}.btn {width: 350px;height: 35px;line-height: 35px;cursor: pointer;margin-top: 20px;border-radius: 3px;background-color: #E9686B;color: white;border: none;font-size: 15px;}.title{margin-top: 5px;font-size: 18px;color: #E9686B;}
</style>
</html>
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>授权</title>
</head><body style="margin: 0px"><div class="container"><h3 th:text="${clientId}+' 请求授权,该应用将获取你的以下信息'"></h3><p>昵称,头像和性别</p>授权后表明你已同意 <a  href="#boot" style="color: #E9686B" th:text="${clientId}+'服务协议'"></a><form method="post" action="/oauth/authorize"><input type="hidden" name="user_oauth_approval" value="true"><div th:each="item:${scopes}"><input type="radio" th:name="'scope.'+${item}" value="true" hidden="hidden" checked="checked"/></div><button class="btn" type="submit"> 同意/授权</button></form>
</div>
</body><style>html{padding: 0px;margin: 0px;}.title-left a{color: white;}.container{clear: both;text-align: center;}.btn {width: 350px;height: 35px;line-height: 35px;cursor: pointer;margin-top: 20px;border-radius: 3px;background-color: #E9686B;color: white;border: none;font-size: 15px;}
</style>
</html>

页面跳转的控制

@Controller
@SessionAttributes("authorizationRequest")
public class CustomAuthController {/*** 跳到自定义登录页面,需要在security配置该path* @return*/@RequestMapping("/mylogin")public String getMyLogin(){return "mylogin";}/*** 跳到自定义授权页面(oauth2默认使用该path)* @param model* @param request* @return* @throws Exception*/@RequestMapping("/oauth/confirm_access")public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");ModelAndView mv = new ModelAndView();mv.setViewName("mygrant");mv.addObject("clientId", authorizationRequest.getClientId());mv.addObject("scopes",authorizationRequest.getScope());return mv;}
}

SecurityConfig配置修改

package com.jwolf.oauth2ssoserver.config;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate UserDetailsService userDetailsService;@Autowiredprivate PasswordEncoder passwordEncoder;@Overrideprotected void configure(HttpSecurity http) throws Exception {// http.formLogin().and().authorizeRequests().anyRequest().authenticated()http.formLogin().loginPage("/mylogin") //登录页view,默认/login.loginProcessingUrl("/authentication/form")//与登录页form提交的url一致,.and().authorizeRequests().antMatchers("/user/xxx", "/mylogin").permitAll().anyRequest().authenticated().and().logout().logoutUrl("/logout") //默认logout//.logoutSuccessUrl("/xxxx").deleteCookies("OAUTH2-CLIENT2-SESSIONID","OAUTH2-CLIENT1-SESSIONID").and().csrf().disable();}@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);}
}

美化效果

2.jwt增强——默认只有client_id,user_name,exp,权限等字段,可以通过jwt增强

 @Overridepublic void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();List<TokenEnhancer> delegates = new ArrayList<>();delegates.add((oAuth2AccessToken, oAuth2Authentication) -> {Map<String, Object> info = new HashMap<>();info.put("info1", "111111");info.put("info2", "222222");((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);return oAuth2AccessToken;});//注意jwtAccessTokenConverter一定要在其它增强链最后,它的作用是生成jwt字符串delegates.add(jwtAccessTokenConverter());tokenEnhancerChain.setTokenEnhancers(delegates);endpoints.tokenStore(jwtTokenStore()).userDetailsService(userDetailsService).tokenEnhancer(tokenEnhancerChain);}

3.SSO client端权限

启动类开启权限开关(多种),默认关闭

@EnableGlobalMethodSecurity(prePostEnabled = true)

业务接口新增权限注解

    @PreAuthorize("hasAuthority('super')")//@PreAuthorize("hasAnyRole('ROLE_NORMAL')")//@PreAuthorize("hasPermission('')")。。。。。

只有有对应authority的user才能访问

4.微信,QQ,github等第三方认证登录的秘密

较大的几个社交平台都提供了oauth2授权登录(用的最严格也是最常用的授权码授权,如果是第二方可考虑简单一点的授权方式),其实是它们提供了一个较大的授权服务器,成为它们的开发者后申请到的appid,appsecret同自己的授权服务器Oauth2配置(如图AuthorizitionServerConfig里配置的client和secret),授权回调url等同于oauth2配置的redirectUrl,但它们的配置一定inMemory,而是jdbc存储,不用重启实时生效的,第三方授权登录本质也是sso,一种跨域的sso. 集成第三方授权配置参考:

security:oauth2:client:client-id: VXXXXXXXXclient-secret: FgSvYXXdDFgSvYXXdDaccess-token-uri: http://openapi.baidu.com/oauth/2.0/tokenuser-authorization-uri: http://openapi.baidu.com/oauth/2.0/authorizeresource:userInfoUri: https://openapi.baidu.com/rest/2.0/passport/users/getInfo

5.oauth2繁杂的授权码认证流程框架内部直接走完了,现在来看细节。其它授权方式及刷新令牌参考,简要步骤

  • 用户先在授权服务器进行认证
  • 认证成功后用户再进行授权
  • 授权之后授权服务器返回给客户端一个授权码
  • 客户端再使用获取到的授权码到授权服务器换取访问令牌
  • 客户端使用访问令牌到资源服务器获取用户资源

5.1 oauth2配置client1新增一个允许的redirectUrl,如https://jd.com

5.2.获取授权码

直接访问浏览器访问:http://localhost:9402/oauth/authorize?client_id=client1&redirect_uri=http://baidu.com&response_type=code

这里有个坑:

这里测试一般使用http://baidu.com测试,使用http://jd.com测试回调后授权码code没有,注意单点登录登录后授权页面这个链接http://localhost:9402/oauth/authorize?client_id=client1&redirect_uri=http://localhost:9502/user/mylogin&response_type=code&scope=all&state=F0ECjt(FOECjt不是授权码)

登录并同意授权后从响应的链接百度一下,你就知道 获取的授权码

5.3.postman发送POST请求,注意相关参数与授权链接的一直,并且只能访问一次

使用access_token访问资源服务器资源,可以使用postman Auth功能,其实就是通过1,2,3步骤添加了一个Authorization请求头,也可以直接http://localhost:9502/user/getCurrentUser?access_token=上面的access_token. 注意@EnableOAuth2Sso不是@EnableResourceServer复合注解,需要通过access_token手动访问资源服务器接口必须启动类加上@EnableResourceServer但是加上后所有的sso客户端所有资源都会被保护,不能直接访问了,需要另外的处理。。。(资源服务器与sso客户端区别??)

刷新令牌:http://localhost:9402/oauth/token?grant_type=refresh_token&client_id=client1&client_secret=secret1&scope=all&refresh_token=上面的freshToken,可获取新的access_token及新的refresh_token。

刷新令牌(refresh_token)也可以用来获取访问令牌(access_token),使用刷新令牌获取访问令牌的过程并不是一种独立的授权方式。刷新令牌只是授权之后随着访问令牌一起返回来的结果而已,而且只有授权码授权方式和密码授权方式才会有刷新令牌,而简化授权方式和客户端授权方式都没有刷新令牌。访问令牌一般都有一个较短的有效期,在有效期内,用户可以使用访问令牌到资源服务器获取用户资源,但是过了有效期,就需要用户重新进行认证并授权,为了减少用户重复认证并授权的复杂过程,就引入了刷新令牌。刷新令牌一般也有一个有效期,但比访问令牌的有效期要长一些,在刷新令牌的有效期之内,可以使用刷新令牌到授权服务器重新获取访问令牌(同时重新获取刷新令牌),但如果刷新令牌过期了,就无法使用刷新令牌来重新获取访问令牌了,此时必须通过认证并授权的方式来获取。

TODO: access_token濒临过期,如何利用refresh_token获取新的access_token让用户无感知????

springcloud2.2.1 oauth2实现用户认证授权及sso相关推荐

  1. Spring Security Oauth2 JWT 实现用户认证授权功能

    Spring Security Oauth2 JWT 一 用户认证授权 1. 需求分析 1.1 用户认证与授权 什么是用户身份认证? 用户身份认证即用户去访问系统资源时系统要求验证用户的身份信息,身份 ...

  2. 如何实现用户认证授权系统

    实现用户认证授权系统的方法如下: 首先,统一用户管理系统在设计时就要能建立一个能适应各种系统权限管理要求的权限模型. 对于己建立的老系统,各系统将自己的用户角色管理,角色一权限管理等部分抽离出来,统一 ...

  3. SpringCloud Gateway 集成 oauth2 实现统一认证授权_03

    文章目录 一.网关搭建 1. 引入依赖 2. 配置文件 3. 增加权限管理器 4. 自定义认证接口管理类 5. 增加网关层的安全配置 6. 搭建授权认证中心 二.搭建产品服务 2.1. 创建boot项 ...

  4. Spring Cloud OAuth2 实现用户认证及单点登录

    OAuth 2 有四种授权模式,分别是授权码模式(authorization code).简化模式(implicit).密码模式(resource owner password credentials ...

  5. SpringCloud整合spring security+ oauth2+Redis实现认证授权

    文章目录 设置通用父工程依赖 构建eureka注册中心 构建认证授权服务 配置文件设置 Security配置类 授权服务配置类 登录实现 测试验证 设置通用父工程依赖 在微服务构建中,我们一般用一个父 ...

  6. SpringBoot+SpringSecurity之多模块用户认证授权同步

    在之前的文章里介绍了SpringBoot和SpringSecurity如何继承.之后我们需要考虑另外一个问题:当前微服务化也已经是大型网站的趋势,当我们的项目采用微服务化架构时,往往会出现如下情况: ...

  7. 微服务商城系统(十) Spring Security Oauth2 + JWT 用户认证

    文章目录 一.用户认证分析 1.认证 与 授权 2.单点登录 3.第三方账号登录 4.第三方认证 5.认证技术方案 6.Security Oauth 2.0 入门 7. 资源服务授权 (1)资源服务授 ...

  8. 阐述Spring security实现用户认证授权的原理----基于session实现认证的方式

    一.认证流程 基于Session认证方式的流程是,用户认证成功后,在服务端生成用户相关的数据保存在session(当前会话),而发 给客户端 sesssion_id 存放到 cookie 中,这样用客 ...

  9. 实战干货!Spring Cloud Gateway 整合 OAuth2.0 实现分布式统一认证授权!

    今天这篇文章介绍一下Spring Cloud Gateway整合OAuth2.0实现认证授权,涉及到的知识点有点多,有不清楚的可以看下陈某的往期文章. 文章目录如下: 微服务认证方案 微服务认证方案目 ...

最新文章

  1. 2021-08-25556. 下一个更大元素 III
  2. Android onLoadFinished与onLoaderReset
  3. 数据结构——插入排序
  4. linux下运行程序后出现段错误的原因和解决案例
  5. 服务器好玩的项目_听说女神还没买到回家的车票,程序员小P偷偷架起了服务器...
  6. Oracle 10gR2 Psu 相关
  7. odata数据绑定_如何使用用于SQL Server集成服务的OData源将数据导入SQL Server数据库
  8. 【LeetCode】【数组】题号:414,第三大的数
  9. 1月16日新经济智库大会聚焦数字经济,议程、直播全收藏
  10. 阿里巴巴Java开发文档2020版学习-常量定义
  11. Geolocation API
  12. matlab振荡环节相频特性,自动控制原理第五章频率特性)汇总.ppt
  13. 【WIN】【C++】遍历文件夹下所有文件
  14. SQL教程之使用 dbt 和 SQLfluff 整理 SQL
  15. 计算机科学与技术专业申请理由,【智能科技学院】计算机科学与技术专业开展2020版工程教育认证申请书要求讨论会...
  16. Uber 《Go语言编程规范》学习笔记(一)
  17. 【总结】大学四年来,用过的一些网站整理
  18. Jetpack(七)—— Room
  19. JavaScript中的数据结构和算法
  20. Kubernetes--k8s--进阶--全面了解HPA--部署HPA实现高可用和成本控制

热门文章

  1. 【C++学习笔记】处理类型和自定义数据结构
  2. ubuntu为软件设定图标
  3. firefly-rk3288开发板Linux驱动——AT24C02 E2PROM驱动
  4. 苹果手机屏幕上的圆点怎么设置?(开启悬浮按钮)
  5. PTN/IPRAN技术介绍及发展史
  6. 用HTML编写携程旅行,StaticHtmlPage(仿照携程写的静态网页)
  7. docker镜像编译与docker-compose部署与编排
  8. Xcode 常用编译选项设置
  9. Python+Excel 华尔街的一股清流
  10. 【Nunit入门系列讲座 1】Nunit的安装及功能介绍