【ASP.NET Core分布式项目实战】(三)整理IdentityServer4 MVC授权、Consent功能实现...
本博客根据http://video.jessetalk.cn/my/course/5视频整理(内容可能会有部分,推荐看源视频学习)
前言
由于之前的博客都是基于其他的博客进行开发,现在重新整理一下方便以后后期使用与学习
新建IdentityServer4服务端
服务端也就是提供服务,如QQ Weibo等。
新建项目解决方案AuthSample.
新建一个ASP.NET Core Web Application 项目MvcCookieAuthSample,选择模板Web 应用程序 不进行身份验证。
给网站设置默认地址 http://localhost:5000
第一步:添加Nuget包:IdentityServer4
添加IdentityServer4 引用:
Install-Package IdentityServer4
第二步:添加Config.cs配置类
然后添加配置类Config.cs:
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using IdentityServer4; using IdentityServer4.Models; using IdentityServer4.Test;namespace MvcCookieAuthSample {public class Config{//所有可以访问的Resourcepublic static IEnumerable<ApiResource> GetApiResources(){return new List<ApiResource>(){new ApiResource("api1","API Application")};}//客户端public static IEnumerable<Client> GetClients(){return new List<Client>{new Client{ClientId="mvc",AllowedGrantTypes=GrantTypes.Implicit,//模式:隐式模式ClientSecrets={//私钥new Secret("secret".Sha256())},AllowedScopes={//运行访问的资源 IdentityServerConstants.StandardScopes.Profile,IdentityServerConstants.StandardScopes.OpenId,},RedirectUris={"http://localhost:5001/signin-oidc"},//跳转登录到的客户端的地址PostLogoutRedirectUris={"http://localhost:5001/signout-callback-oidc"},//跳转登出到的客户端的地址RequireConsent=false//是否需要用户点击确认进行跳转 }};}//测试用户public static List<TestUser> GetTestUsers(){return new List<TestUser>{new TestUser{SubjectId="10000",Username="wyt",Password="password"}};}//定义系统中的资源public static IEnumerable<IdentityResource> GetIdentityResources(){return new List<IdentityResource>{//这里实际是claims的返回资源new IdentityResources.OpenId(),new IdentityResources.Profile(),new IdentityResources.Email()};}} }
View Code
第三步:添加Startup配置
引用命名空间:
using IdentityServer4;
然后打开Startup.cs 加入如下:
services.AddIdentityServer().AddDeveloperSigningCredential()//添加开发人员签名凭据.AddInMemoryApiResources(Config.GetApiResources())//添加内存apiresource.AddInMemoryClients(Config.GetClients())//添加内存client.AddInMemoryIdentityResources(Config.GetIdentityResources())//添加系统中的资源.AddTestUsers(Config.GetTestUsers());//添加测试用户
public void Configure(IApplicationBuilder app, IHostingEnvironment env) {...app.UseIdentityServer();... }
注册登录实现
我们还需要新建一个ViewModels,在ViewModels中新建RegisterViewModel.cs和LoginViewModel.cs来接收表单提交的值以及来进行强类型视图
using System.ComponentModel.DataAnnotations;namespace MvcCookieAuthSample.ViewModels {public class RegisterViewModel{[Required]//必须的[DataType(DataType.EmailAddress)]//内容检查是否为邮箱public string Email { get; set; }[Required]//必须的[DataType(DataType.Password)]//内容检查是否为密码public string Password { get; set; }[Required]//必须的[DataType(DataType.Password)]//内容检查是否为密码public string ConfirmedPassword { get; set; }} }
View Code
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks;namespace MvcCookieAuthSample.ViewModels {public class LoginViewModel{[Required]public string UserName { get; set; }[Required]//必须的[DataType(DataType.Password)]//内容检查是否为密码public string Password { get; set; }} }
View Code
在Controllers文件夹下新建AdminController.cs
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc;namespace MvcCookieAuthSample.Controllers {public class AdminController : Controller{public IActionResult Index(){return View();}} }
View Code
在Views文件夹下新建Admin文件夹,并在Admin文件夹下新建Index.cshtml
@{ViewData["Title"] = "Admin"; } <h2>@ViewData["Title"]</h2><p>Admin Page</p>
View Code
在Controllers文件夹下新建AccountController.cs
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using IdentityServer4.Test; using Microsoft.AspNetCore.Identity; using MvcCookieAuthSample.ViewModels; using Microsoft.AspNetCore.Authentication;namespace MvcCookieAuthSample.Controllers {public class AccountController : Controller{private readonly TestUserStore _users;public AccountController(TestUserStore users){_users = users;}//内部跳转private IActionResult RedirectToLocal(string returnUrl){if (Url.IsLocalUrl(returnUrl)){//如果是本地return Redirect(returnUrl);}return RedirectToAction(nameof(HomeController.Index), "Home");}//添加验证错误private void AddError(IdentityResult result){//遍历所有的验证错误foreach (var error in result.Errors){//返回error到modelModelState.AddModelError(string.Empty, error.Description);}}public IActionResult Register(string returnUrl = null){ViewData["returnUrl"] = returnUrl;return View();}[HttpPost]public async Task<IActionResult> Register(RegisterViewModel registerViewModel, string returnUrl = null){return View();}public IActionResult Login(string returnUrl = null){ViewData["returnUrl"] = returnUrl;return View();}[HttpPost]public async Task<IActionResult> Login(LoginViewModel loginViewModel, string returnUrl = null){if (ModelState.IsValid){ViewData["returnUrl"] = returnUrl;var user = _users.FindByUsername(loginViewModel.UserName);if (user==null){ModelState.AddModelError(nameof(loginViewModel.UserName), "UserName not exists");}else{if (_users.ValidateCredentials(loginViewModel.UserName,loginViewModel.Password)){//是否记住var prop = new AuthenticationProperties{IsPersistent = true,ExpiresUtc = DateTimeOffset.UtcNow.Add(TimeSpan.FromMinutes(30))};await Microsoft.AspNetCore.Http.AuthenticationManagerExtensions.SignInAsync(HttpContext, user.SubjectId, user.Username, prop);}}return RedirectToLocal(returnUrl);}return View();}public async Task<IActionResult> Logout(){await HttpContext.SignOutAsync();return RedirectToAction("Index", "Home");}} }
View Code
然后在Views文件夹下新增Account文件夹并新增Register.cshtml与Login.cshtml视图
@{ViewData["Title"] = "Register"; }@using MvcCookieAuthSample.ViewModels; @model RegisterViewModel;<h2>@ViewData["Title"]</h2> <h3>@ViewData["Message"]</h3><div class="row"><div class="col-md-4">@* 这里将asp-route-returnUrl="@ViewData["returnUrl"],就可以在进行register的post请求的时候接收到returnUrl *@<form method="post" asp-route-returnUrl="@ViewData["returnUrl"]"><h4>Create a new account.</h4><hr />@*统一显示错误信息*@<div class="text-danger" asp-validation-summary="All"></div><div class="form-group"><label asp-for="Email"></label><input asp-for="Email" class="form-control" /><span asp-validation-for="Email" class="text-danger"></span></div><div class="form-group"><label asp-for="Password"></label><input asp-for="Password" class="form-control" /><span asp-validation-for="Password" class="text-danger"></span></div><div class="form-group"><label asp-for="ConfirmedPassword"></label><input asp-for="ConfirmedPassword" class="form-control" /><span asp-validation-for="ConfirmedPassword" class="text-danger"></span></div><button type="submit" class="btn btn-default">Register</button></form></div> </div>
View Code
@{ViewData["Title"] = "Login"; }@using MvcCookieAuthSample.ViewModels; @model LoginViewModel;<div class="row"><div class="col-md-4"><section><form method="post" asp-controller="Account" asp-action="Login" asp-route-returnUrl="@ViewData["returnUrl"]"><h4>Use a local account to log in.</h4><hr />@*统一显示错误信息*@<div class="text-danger" asp-validation-summary="All"></div><div class="form-group"><label asp-for="UserName"></label><input asp-for="UserName" class="form-control" /><span asp-validation-for="UserName" class="text-danger"></span></div><div class="form-group"><label asp-for="Password"></label><input asp-for="Password" type="password" class="form-control" /><span asp-validation-for="Password" class="text-danger"></span></div><div class="form-group"><button type="submit" class="btn btn-default">Log in</button></div></form></section></div> </div>@section Scripts{@await Html.PartialAsync("_ValidationScriptsPartial") }
View Code
我们接下来要修改_Layout.cshtml视图页面判断注册/登陆按钮是否应该隐藏
完整的_Layout.cshtml代码:
<!DOCTYPE html> <html> <head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>@ViewData["Title"] - MvcCookieAuthSample</title><environment include="Development"><link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" /><link rel="stylesheet" href="~/css/site.css" /></environment><environment exclude="Development"><link rel="stylesheet" href="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.7/css/bootstrap.min.css"asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute" /><link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" /></environment> </head> <body><nav class="navbar navbar-inverse navbar-fixed-top"><div class="container"><div class="navbar-header"><button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse"><span class="sr-only">Toggle navigation</span><span class="icon-bar"></span><span class="icon-bar"></span><span class="icon-bar"></span></button><a asp-area="" asp-controller="Home" asp-action="Index" class="navbar-brand">MvcCookieAuthSample</a></div><div class="navbar-collapse collapse"><ul class="nav navbar-nav"><li><a asp-area="" asp-controller="Home" asp-action="Index">Home</a></li><li><a asp-area="" asp-controller="Home" asp-action="About">About</a></li><li><a asp-area="" asp-controller="Home" asp-action="Contact">Contact</a></li></ul>@if (User.Identity.IsAuthenticated){<form asp-action="Logout" asp-controller="Account" method="post"><ul class="nav navbar-nav navbar-right"><li><a title="Welcome" asp-controller="Admin" asp-action="Index">@User.Identity.Name</a></li><li><button type="submit" class="btn btn-link navbar-btn navbar-link">Log out</button></li></ul></form>}else{<ul class="nav navbar-nav navbar-right"><li><a asp-area="" asp-controller="Account" asp-action="Register">Register</a></li><li><a asp-area="" asp-controller="Account" asp-action="Login">Log in</a></li></ul>}</div></div></nav><div class="container body-content">@RenderBody()<hr /><footer><p>© 2018 - MvcCookieAuthSample</p></footer></div><environment include="Development"><script src="~/lib/jquery/dist/jquery.js"></script><script src="~/lib/bootstrap/dist/js/bootstrap.js"></script><script src="~/js/site.js" asp-append-version="true"></script></environment><environment exclude="Development"><script src="https://ajax.aspnetcdn.com/ajax/jquery/jquery-2.2.0.min.js"asp-fallback-src="~/lib/jquery/dist/jquery.min.js"asp-fallback-test="window.jQuery"crossorigin="anonymous"integrity="sha384-K+ctZQ+LL8q6tP7I94W+qzQsfRV2a+AfHIi9k8z8l9ggpc8X+Ytst4yBo/hH+8Fk"></script><script src="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.7/bootstrap.min.js"asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.min.js"asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal"crossorigin="anonymous"integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"></script><script src="~/js/site.min.js" asp-append-version="true"></script></environment>@RenderSection("Scripts", required: false) </body> </html>
View Code
最后给AdminController加上 [Authorize] 特性标签即可
然后我们就可以运行网站,输入用户名和密码进行登录了
新建客户端
新建一个MVC网站MvcClient
dotnet new mvc --name MvcClient
给网站设置默认地址 http://localhost:5001
MVC的网站已经内置帮我们实现了Identity,所以我们不需要再额外添加Identity引用
添加认证
services.AddAuthentication(options => {options.DefaultScheme = "Cookies";//使用Cookies认证options.DefaultChallengeScheme = "oidc";//使用oidc }) .AddCookie("Cookies")//配置Cookies认证 .AddOpenIdConnect("oidc",options=> {//配置oidcoptions.SignInScheme = "Cookies";options.Authority = "http://localhost:5000";options.RequireHttpsMetadata = false;options.ClientId = "mvc";options.ClientSecret = "secret";options.SaveTokens = true; });
在管道中使用Authentication
app.UseAuthentication();
接下来我们在HomeController上打上 [Authorize] 标签,然后启动运行
我们这个时候访问首页http://localhost:5001会自动跳转到ocalhost:5000/account/login登录
登录之后会自动跳转回来
我们可以在Home/About页面将claim的信息显示出来
@{ViewData["Title"] = "About"; } <h2>@ViewData["Title"]</h2> <h3>@ViewData["Message"]</h3><dl>@foreach (var claim in User.Claims){<dt>@claim.Type</dt><dt>@claim.Value</dt>} </dl>
View Code
这边的内容是根据我们在IdentityServer服务中定义的返回资源决定的
Consent功能实现
首先在ViewModels文件夹下创建两个视图模型
ScopeViewModel.cs
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks;namespace MvcCookieAuthSample.ViewModels {//领域public class ScopeViewModel{public string Name { get; set; }public string DisplayName { get; set; }public string Description { get; set; }public bool Emphasize { get; set; }public bool Required { get; set; }public bool Checked { get; set; }} }
View Code
ConsentViewModel.cs
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks;namespace MvcCookieAuthSample.ViewModels {public class ConsentViewModel{public string ClientId { get; set; }public string ClientName { get; set; }public string ClientUrl { get; set; }public string ClientLogoUrl { get; set; }public bool AllowRememberConsent { get; set; }public IEnumerable<ScopeViewModel> IdentityScopes { get; set; }public IEnumerable<ScopeViewModel> ResourceScopes { get; set; }} }
View Code
我们在MvcCookieAuthSample项目中添加新控制器ConsentController
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using MvcCookieAuthSample.ViewModels; using IdentityServer4.Models; using IdentityServer4.Services; using IdentityServer4.Stores;namespace MvcCookieAuthSample.Controllers {public class ConsentController : Controller{private readonly IClientStore _clientStore;private readonly IResourceStore _resourceStore;private readonly IIdentityServerInteractionService _identityServerInteractionService;public ConsentController(IClientStore clientStore, IResourceStore resourceStore, IIdentityServerInteractionService identityServerInteractionService){_clientStore = clientStore;_resourceStore = resourceStore;_identityServerInteractionService = identityServerInteractionService;}private async Task<ConsentViewModel> BuildConsentViewModel(string returnUrl){var request =await _identityServerInteractionService.GetAuthorizationContextAsync(returnUrl);if (request == null)return null;var client =await _clientStore.FindEnabledClientByIdAsync(request.ClientId);var resources =await _resourceStore.FindEnabledResourcesByScopeAsync(request.ScopesRequested);return CreateConsentViewModel(request, client, resources);}private ConsentViewModel CreateConsentViewModel(AuthorizationRequest request,Client client,Resources resources){var vm = new ConsentViewModel();vm.ClientName = client.ClientName;vm.ClientLogoUrl = client.LogoUri;vm.ClientUrl = client.ClientUri;vm.AllowRememberConsent = client.AllowRememberConsent;vm.IdentityScopes = resources.IdentityResources.Select(i => CreateScopeViewModel(i));vm.ResourceScopes = resources.ApiResources.SelectMany(i =>i.Scopes).Select(i=>CreateScopeViewModel(i));return vm;}private ScopeViewModel CreateScopeViewModel(IdentityResource identityResource){return new ScopeViewModel{Name = identityResource.Name,DisplayName = identityResource.DisplayName,Description = identityResource.Description,Required = identityResource.Required,Checked = identityResource.Required,Emphasize = identityResource.Emphasize};}private ScopeViewModel CreateScopeViewModel(Scope scope){return new ScopeViewModel{Name = scope.Name,DisplayName = scope.DisplayName,Description = scope.Description,Required = scope.Required,Checked = scope.Required,Emphasize = scope.Emphasize};}[HttpGet]public async Task<IActionResult> Index(string returnUrl){var model =await BuildConsentViewModel(returnUrl);if (model==null){}return View(model);}} }
View Code
然后新建Idenx.cshtml视图和_ScopeListitem.cshtml分部视图
_ScopeListitem.cshtml
@using MvcCookieAuthSample.ViewModels; @model ScopeViewModel<li><label><input type="checkbox" name="ScopesConsented" id="scopes_@Model.Name" value="@Model.Name" checked="@Model.Checked" disabled="@Model.Required"/><strong>@Model.Name</strong>@if (Model.Emphasize){<span class="glyphicon glyphicon-exclamation-sign"></span>}</label>@if (string.IsNullOrWhiteSpace(Model.Description)){<div><label for="scopes_@Model.Name">@Model.Description</label></div>} </li>
View Code
Idenx.cshtml
@using MvcCookieAuthSample.ViewModels; @model ConsentViewModel <p>Consent Page</p> <!--Client Info--> <div class="row page-header"><div class="col-sm-10">@if (!string.IsNullOrWhiteSpace(Model.ClientLogoUrl)){<div><img src="@Model.ClientLogoUrl" /></div>}<h1>@Model.ClientName<small>希望使用你的账户</small></h1></div> </div><!--Scope Info--> <div class="row"><div class="col-sm-8"><form asp-action="Index">@if (Model.IdentityScopes.Any()){<div><div class="panel-heading"><span class="glyphicon glyphicon-user"></span>用户信息</div><ul class="list-group">@foreach (var scope in Model.IdentityScopes){@Html.Partial("_ScopeListitem",scope)}</ul></div>}@if (Model.ResourceScopes.Any()){<div><div class="panel-heading"><span class="glyphicon glyphicon-tasks"></span>应用权限</div><ul class="list-group">@foreach (var scope in Model.ResourceScopes){@Html.Partial("_ScopeListitem",scope)}</ul></div>}</form></div> </div>
View Code
最后我们修改Config.cs,增加一些信息
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using IdentityServer4; using IdentityServer4.Models; using IdentityServer4.Test;namespace MvcCookieAuthSample {public class Config{//所有可以访问的Resourcepublic static IEnumerable<ApiResource> GetApiResources(){return new List<ApiResource>(){new ApiResource("api1","API Application")};}//客户端public static IEnumerable<Client> GetClients(){return new List<Client>{new Client{ClientId="mvc",AllowedGrantTypes=GrantTypes.Implicit,//模式:隐式模式ClientSecrets={//私钥new Secret("secret".Sha256())},AllowedScopes={//运行访问的资源 IdentityServerConstants.StandardScopes.Profile,IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Email,},RedirectUris={"http://localhost:5001/signin-oidc"},//跳转登录到的客户端的地址PostLogoutRedirectUris={"http://localhost:5001/signout-callback-oidc"},//跳转登出到的客户端的地址RequireConsent=true,//是否需要用户点击确认进行跳转,改为点击确认后进行跳转ClientName="MVC Clent",ClientUri="http://localhost:5001",LogoUri="https://chocolatey.org/content/packageimages/aspnetcore-runtimepackagestore.2.0.0.png",AllowRememberConsent=true,}};}//测试用户public static List<TestUser> GetTestUsers(){return new List<TestUser>{new TestUser{SubjectId="10000",Username="wyt",Password="password",}};}//定义系统中的资源public static IEnumerable<IdentityResource> GetIdentityResources(){return new List<IdentityResource>{//这里实际是claims的返回资源new IdentityResources.OpenId(),new IdentityResources.Profile(),new IdentityResources.Email()};}} }
我们这个时候访问首页http://localhost:5001会自动跳转到ocalhost:5000/account/login登录
登录之后会自动跳转到登录确认页面
转载于:https://www.cnblogs.com/lonelyxmas/p/10618795.html
【ASP.NET Core分布式项目实战】(三)整理IdentityServer4 MVC授权、Consent功能实现...相关推荐
- ASP.NET Core分布式项目实战(详解oauth2授权码流程)--学习笔记
最近公司产品上线,通宵加班了一个月,一直没有更新,今天开始恢复,每日一更,冲冲冲 任务13:详解oauth2授权码流程 我们即将开发的产品有一个用户 API,一个项目服务 API,每个服务都需要认证授 ...
- ASP.NET Core分布式项目实战(集成ASP.NETCore Identity)--学习笔记
任务24:集成ASP.NETCore Identity 之前在 Index 页面写了一个 strong 标签,需要加个判断再显示,不然为空没有错误的时候也会显示 @if (!ViewContext.M ...
- ASP.NET Core分布式项目实战(Consent 确认逻辑实现)--学习笔记
任务22:Consent 确认逻辑实现 接下来,我们会在上一节的基础上添加两个按钮,同意和不同意,点击之后会把请求 post 到 ConsentController 处理,如果同意会通过 return ...
- ASP.NET Core分布式项目实战(运行Consent Page)--学习笔记
任务21:运行Consent Page 修改 Config.cs 中的 RequireConsent 为 true,这样登录的时候就会跳转到 Consent 页面 修改 ConsentControll ...
- ASP.NET Core分布式项目实战(Consent Controller Get请求逻辑实现)--学习笔记
任务20:Consent Controller Get请求逻辑实现 接着上一节的思路,实现一下 ConsentController 根据流程图在构造函数注入 IClientStore,IResourc ...
- ASP.NET Core分布式项目实战(oauth2 + oidc 实现 client部分)--学习笔记
任务16:oauth2 + oidc 实现 client部分 实现 client 之前启动一下上一节的 server,启动之前需要清除一些代码 注释 Program 的 MigrateDbContex ...
- ASP.NET Core分布式项目实战(oauth2 + oidc 实现 server部分)--学习笔记
任务15:oauth2 + oidc 实现 server部分 基于之前快速入门的项目(MvcCookieAuthSample): ASP.NET Core快速入门(第5章:认证与授权)--学习笔记 A ...
- ASP.NET Core分布式项目实战(客户端集成IdentityServer)--学习笔记
任务9:客户端集成IdentityServer 新建 API 项目 dotnet new webapi --name ClientCredentialApi 控制器添加验证 using Microso ...
- ASP.NET Core分布式项目实战(第三方ClientCredential模式调用)--学习笔记
任务10:第三方ClientCredential模式调用 创建一个控制台程序 dotnet new console --name ThirdPartyDemo 添加 Nuget 包:IdentityM ...
- ASP.NET Core分布式项目实战(业务介绍,架构设计,oAuth2,IdentityServer4)--学习笔记...
任务4:第一章计划与目录 敏捷产品开发流程 原型预览与业务介绍 整体架构设计 API 接口设计 / swagger Identity Server 4 搭建登录 账号 API 实现 配置中心 任务5: ...
最新文章
- 使用java+TestNG进行接口回归测试 1
- 计算机信息安全专业留学,2021美国信息安全专业排名Top50大学!
- golang gorm 基本使用
- datagrid显示mysql_WPF DataGrid显示MySQL查询信息,且可删除、修改、插入 (原发布 csdn 2018-10-13 20:07:28)...
- apache htpasswd.exe创建密码
- 1.5 编程基础之循环控制 36 计算多项式的值 python
- 【Pytorch】expand()用法==》扩展某个维度
- @excel 注解_Java中注解学习系列教程-3
- JVM学习之GC参数设置
- elasticsearch2.x优化小结(单节点)
- java list 排序_java 对list进行排序
- java读取摄像头视屏流,Java 摄像头视频获取
- 探讨破解3G今日困局之策
- html怎样使图片自动旋转,css怎么让图片旋转?
- 年终总结,怎么写领导才满意?
- Android11.0(R) MTK 预置可卸载app恢复出厂不恢复(仿RK方案)
- js-面向对象的程序设计,函数表达式
- Android 内核源码编译记录
- Linux/Windows快速镜像安装包下载
- 5G无线定位技术标准化及发展趋势
热门文章
- 【pytest官方文档】解读-fixtures函数和测试函数的参数化
- 【pytest】之parameterize()参数化,实现测试方法数据化
- 通过U盘安装windows简易教程
- macbook pro怎么养小宠物?macbook pro养宠物设置方法
- vue项目中如何简单的读取声音文件
- 中考计算机考试辽宁,2019年辽宁中考考试时间安排,辽宁中考考试科目时间安排表...
- matlab相机标定工具箱讲解,matlab 相机标定工具箱
- Three.js和其它webgl框架
- Redis - 一个简单的抢红包小项目
- 线程池函数1 - 异步调用函数