近日,应一位朋友的邀请写了个Asp.Net Core基于JWT认证的数据接口网关Demo。朋友自己开了个公司,接到的一个升级项目,客户要求用Aps.Net Core做数据网关服务且基于JWT认证实现对前后端分离的数据服务支持,于是想到我一直做.Net开发,问我是否对.Net Core有所了解?能不能做个简单Demo出来看看?我说,分道扬镳之后我不是调用别人的接口就是提供接口给别人调用,于是便有了以下示例代码。
示例要求能演示获取Token及如何使用该Token访问数据资源,在Demo中实现了JWT的颁发及验证以及重写一个ActionAuthorizeAttribute实现对具体数据接口的调用权限控制,先看一下项目截图:
[项目截图]
项目文件介绍
解决方案下只有一个项目,项目名称就叫Jwt.Gateway,包含主要文件有:
- Controllers目录下的ApiActionFilterAttribute.cs文件,继承Microsoft.AspNetCore.Mvc.Filters.ActionFilterAttribute,用于校验接口调用者对具体接口的访问权限。
- Controllers目录下的ApiBase.cs文件,继承Microsoft.AspNetCore.Mvc.Controller,具有Microsoft.AspNetCore.Authorization.Authorize特性引用,用于让所有数据接口用途的控制器继承,定义有CurrentAppKey属性(来访应用程序的身份标识)并在OnActionExecuting事件中统一分析Claims并赋值。
- Controllers目录下的TokenController.cs控制器文件,用于对调用方应用程序获取及注销Token。
- Controllers目录下的UsersController.cs控制器文件,继承ApiBase.cs,作为数据调用示例。
- MiddleWares目录下的ApiCustomException.cs文件,是一个数据接口的统一异常处理中间件。
- Models目录下的ApiResponse.cs文件,用于做数据接口的统一数据及错误信息输出实体模型。
- Models目录下的User.cs文件,示例数据实体模型。
- Program.cs及Startup.cs文件就不介绍了,随便建个空项目都有。
项目文件代码
ApiActionFilterAttribute.cs
Controllers目录下的ApiActionFilterAttribute.cs文件,继承Microsoft.AspNetCore.Mvc.Filters.ActionFilterAttribute,用于校验接口调用者对具体接口的访问权限。
设想每一个到访的请求都是一个应用程序,每一个应用程序都分配有基本的Key和Password,每一个应用程序具有不同的接口访问权限,所以在具体的数据接口上应该声明该接口所要求的权限值,比如修改用户信息的接口应该在接口方法上声明需要具有“修改用户”的权限,用例:[ApiActionFilter("用户修改")]。
大部分情况下一个接口(方法)对应一个操作,这样基本上就能应付了,但是不排除有时候可能需要多个权限组合进行验证,所以该文件中有一个对多个权限值进行校验的“与”和“和”枚举,用例:[ApiActionFilter(new string[] { "用户修改", "用户录入", "用户删除" },ApiActionFilterAttributeOption.AND)],这样好像就差不多了。
由于在一个接口调用之后可能需要将该接口所声明需要的权限值记入日志等需求,因此权限值集合将被写入到HttpContext.Items["Permissions"]中以方便可能的后续操作访问,看代码:
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Threading.Tasks; 5 using Microsoft.AspNetCore.Mvc.Filters; 6 7 namespace Jwt.Gateway.Controllers 8 { 9 public enum ApiActionFilterAttributeOption 10 { 11 OR,AND 12 } 13 public class ApiActionFilterAttribute : Microsoft.AspNetCore.Mvc.Filters.ActionFilterAttribute 14 { 15 List<string> Permissions = new List<string>(); 16 ApiActionFilterAttributeOption Option = ApiActionFilterAttributeOption.AND; 17 public ApiActionFilterAttribute(string permission) 18 { 19 Permissions.Add(permission); 20 } 21 public ApiActionFilterAttribute(string[] permissions, ApiActionFilterAttributeOption option) 22 { 23 foreach(var permission in permissions) { 24 if (Permissions.Contains(permission)) 25 { 26 continue; 27 } 28 Permissions.Add(permission); 29 } 30 Option = option; 31 } 32 33 public override void OnActionExecuting(ActionExecutingContext context) 34 { 35 var key = GetAppKey(context); 36 List<string> keyPermissions = GetAppKeyPermissions(key); 37 var isAnd = Option == ApiActionFilterAttributeOption.AND; 38 var permissionsCount = Permissions.Count; 39 var keyPermissionsCount = keyPermissions.Count; 40 for (var i = 0; i < permissionsCount; i++) 41 { 42 bool flag = false; 43 for (var j = 0; j < keyPermissions.Count; j++) 44 { 45 if (flag = string.Equals(Permissions[i], keyPermissions[j], StringComparison.OrdinalIgnoreCase)) 46 { 47 break; 48 } 49 } 50 if (flag) 51 { 52 continue; 53 } 54 if (isAnd) 55 { 56 throw new Exception("应用“" + key + "”缺少“" + Permissions[i] + "”的权限"); 57 } 58 } 59 60 context.HttpContext.Items.Add("Permissions", Permissions); 61 62 base.OnActionExecuting(context); 63 } 64 65 private string GetAppKey(ActionExecutingContext context) 66 { 67 var claims = context.HttpContext.User.Claims; 68 if (claims == null) 69 { 70 throw new Exception("未能获取到应用标识"); 71 } 72 var claimKey = claims.ToList().Find(o => string.Equals(o.Type, "AppKey", StringComparison.OrdinalIgnoreCase)); 73 if (claimKey == null) 74 { 75 throw new Exception("未能获取到应用标识"); 76 } 77 78 return claimKey.Value; 79 } 80 private List<string> GetAppKeyPermissions(string appKey) 81 { 82 List<string> li = new List<string> 83 { 84 "用户明细","用户列表","用户录入","用户修改","用户删除" 85 }; 86 return li; 87 } 88 89 } 90 }
ApiBase.cs
Controllers目录下的ApiBase.cs文件,继承Microsoft.AspNetCore.Mvc.Controller,具有Microsoft.AspNetCore.Authorization.Authorize特性引用,用于让所有数据接口用途的控制器继承,定义有CurrentAppKey属性(来访应用程序的身份标识)并在OnActionExecuting事件中统一分析Claims并赋值。
通过验证之后,Aps.Net Core会在HttpContext.User.Claims中将将来访者的身份信息记录下来,我们可以通过该集合得到来访者的身份信息。
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Threading.Tasks; 5 using Microsoft.AspNetCore.Http; 6 using Microsoft.AspNetCore.Mvc; 7 using Microsoft.AspNetCore.Mvc.Filters; 8 9 namespace Jwt.Gateway.Controllers 10 { 11 [Microsoft.AspNetCore.Authorization.Authorize] 12 public class ApiBase : Microsoft.AspNetCore.Mvc.Controller 13 { 14 private string _CurrentAppKey = ""; 15 public string CurrentAppKey { get { return _CurrentAppKey; } } 16 public override void OnActionExecuting(ActionExecutingContext context) 17 { 18 var claims = context.HttpContext.User.Claims.ToList(); 19 var claim = claims.Find(o => o.Type == "appKey"); 20 if (claim == null) 21 { 22 throw new Exception("未通过认证"); 23 } 24 var appKey = claim.Value; 25 if (string.IsNullOrEmpty(appKey)) 26 { 27 throw new Exception("appKey不合法"); 28 } 29 30 _CurrentAppKey = appKey; 31 32 base.OnActionExecuting(context); 33 } 34 } 35 }
TokenController.cs
Controllers目录下的TokenController.cs控制器文件,用于对调用方应用程序获取及注销Token。
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Threading.Tasks; 5 using Microsoft.AspNetCore.Http; 6 using Microsoft.AspNetCore.Mvc; 7 8 namespace Jwt.Gateway.Controllers 9 { 10 [Route("api/[controller]/[action]")] 11 public class TokenController : Controller 12 { 13 private readonly Microsoft.Extensions.Configuration.IConfiguration _configuration; 14 15 public TokenController(Microsoft.Extensions.Configuration.IConfiguration configuration) 16 { 17 _configuration = configuration; 18 } 19 20 // /api/token/get 21 public IActionResult Get(string appKey, string appPassword) 22 { 23 try 24 { 25 if (string.IsNullOrEmpty(appKey)) 26 { 27 throw new Exception("缺少appKey"); 28 } 29 if (string.IsNullOrEmpty(appKey)) 30 { 31 throw new Exception("缺少appPassword"); 32 } 33 if (appKey != "myKey" && appPassword != "myPassword")//固定的appKey及appPassword,实际项目中应该来自数据库或配置文件 34 { 35 throw new Exception("配置不存在"); 36 } 37 38 var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(_configuration["JwtSecurityKey"])); 39 var creds = new Microsoft.IdentityModel.Tokens.SigningCredentials(key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256); 40 var claims = new List<System.Security.Claims.Claim>(); 41 claims.Add(new System.Security.Claims.Claim("appKey", appKey));//仅在Token中记录appKey 42 var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken( 43 issuer: _configuration["JwtTokenIssuer"], 44 audience: _configuration["JwtTokenAudience"], 45 claims: claims, 46 expires: DateTime.Now.AddMinutes(30), 47 signingCredentials: creds); 48 49 return Ok(new Models.ApiResponse { status = 1, message = "OK", data = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(token) }); 50 51 } 52 catch(Exception ex) 53 { 54 return Ok(new Models.ApiResponse { status = 0, message = ex.Message, data = "" }); 55 } 56 } 57 58 // /api/token/delete 59 public IActionResult Delete(string token) 60 { 61 //code: 加入黑名单,使其无效 62 63 return Ok(new Models.ApiResponse { status = 1, message = "OK", data = "" }); 64 } 65 66 67 } 68 }
UsersController.cs
Controllers目录下的UsersController.cs控制器文件,继承ApiBase.cs,作为数据调用示例。
该控制器定义了对User对象常规的 明细、列表、录入、修改、删除 等操作。
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Threading.Tasks; 5 using Microsoft.AspNetCore.Mvc; 6 7 namespace Jwt.Gateway.Controllers 8 { 9 [Produces("application/json")] 10 [Route("api/[controller]/[action]")] 11 public class UsersController : ApiBase 12 { 13 /* 14 * 1.要访问访问该控制器提供的接口请先通过"/api/token/get"获取token 15 * 2.访问该控制器提供的接口http请求头必须具有值为"Bearer+空格+token"的Authorization键,格式参考: 16 * "Authorization"="Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiQXBwIiwiYXBwS2V5IjoibXlLZXkiLCJleHAiOjE1NTE3ODc2MDMsImlzcyI6IkdhdGV3YXkiLCJhdWQiOiJhdWRpZW5jZSJ9.gQ9_Q7HUT31oFyfl533T-bNO5IWD2drl0NmD1JwQkMI" 17 */ 18 19 /// <summary> 20 /// 临时用户测试数据,实际项目中应该来自数据库等媒介 21 /// </summary> 22 static List<Models.User> _Users = null; 23 static object _Lock = new object(); 24 public UsersController() 25 { 26 if (_Users == null) 27 { 28 lock (_Lock) 29 { 30 if (_Users == null) 31 { 32 _Users = new List<Models.User>(); 33 var now = DateTime.Now; 34 for(var i = 0; i < 10; i++) 35 { 36 var num = i + 1; 37 _Users.Add(new Models.User { UserId = num, UserName = "name"+num, UserPassword = "pwd"+num, UserJoinTime = now }); 38 } 39 } 40 } 41 } 42 } 43 44 // /api/users/detail 45 [ApiActionFilter("用户明细")] 46 public IActionResult Detail(long userId) 47 { 48 /* 49 //获取appKey(在ApiBase中写入) 50 var appKey = CurrentAppKey; 51 //获取使用的权限(在ApiActionAuthorizeAttribute中写入) 52 var permissions = HttpContext.Items["Permissions"]; 53 */ 54 55 var user = _Users.Find(o => o.UserId == userId); 56 if (user == null) 57 { 58 throw new Exception("用户不存在"); 59 } 60 61 return Ok(new Models.ApiResponse { data = user, status = 1, message = "OK" }); 62 } 63 64 // /api/users/list 65 [ApiActionFilter("用户列表")] 66 public IActionResult List(int page, int size) 67 { 68 page = page < 1 ? 1 : page; 69 size = size < 1 ? 1 : size; 70 var total = _Users.Count(); 71 var pages = total % size == 0 ? total / size : ((long)Math.Floor((double)total / size + 1)); 72 if (page > pages) 73 { 74 return Ok(new Models.ApiResponse { data = new List<Models.User>(), status = 1, message = "OK", total = total }); 75 } 76 var li = new List<Models.User>(); 77 var startIndex = page * size - size; 78 var endIndex = startIndex + size - 1; 79 if (endIndex > total - 1) 80 { 81 endIndex = total - 1; 82 } 83 for(; startIndex <= endIndex; startIndex++) 84 { 85 li.Add(_Users[startIndex]); 86 } 87 return Ok(new Models.ApiResponse { data = li, status = 1, message = "OK", total = total }); 88 } 89 90 // /api/users/add 91 [ApiActionFilter("用户录入")] 92 public IActionResult Add() 93 { 94 return Ok(new Models.ApiResponse { status = 1, message = "OK" }); 95 } 96 97 // /api/users/update 98 [ApiActionFilter(new string[] { "用户修改", "用户录入", "用户删除" },ApiActionFilterAttributeOption.AND)] 99 public IActionResult Update() 100 { 101 return Ok(new Models.ApiResponse { status = 1, message = "OK" }); 102 } 103 104 // /api/users/delete 105 [ApiActionFilter("用户删除")] 106 public IActionResult Delete() 107 { 108 return Ok(new Models.ApiResponse { status = 1, message = "OK" }); 109 } 110 } 111 }
ApiCustomException.cs
MiddleWares目录下的ApiCustomException.cs文件,是一个数据接口的统一异常处理中间件。
该文件整理并抄袭自:https://www.cnblogs.com/ShenNan/p/10197231.html
在此特别感谢一下作者的先行贡献,并请原谅我无耻的抄袭。
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Threading.Tasks; 5 using Microsoft.AspNetCore.Http; 6 using Microsoft.AspNetCore.Builder; 7 using Microsoft.Extensions.DependencyInjection; 8 9 namespace Jwt.Gateway.MiddleWares 10 { 11 //参考: https://www.cnblogs.com/ShenNan/p/10197231.html 12 13 public enum ApiCustomExceptionHandleType 14 { 15 JsonHandle = 0, 16 PageHandle = 1, 17 Both = 2 18 } 19 public class ApiCustomExceptionMiddleWareOption 20 { 21 public ApiCustomExceptionMiddleWareOption( 22 ApiCustomExceptionHandleType handleType = ApiCustomExceptionHandleType.JsonHandle, 23 IList<PathString> jsonHandleUrlKeys = null, 24 string errorHandingPath = "") 25 { 26 HandleType = handleType; 27 JsonHandleUrlKeys = jsonHandleUrlKeys; 28 ErrorHandingPath = errorHandingPath; 29 } 30 public ApiCustomExceptionHandleType HandleType { get; set; } 31 public IList<PathString> JsonHandleUrlKeys { get; set; } 32 public PathString ErrorHandingPath { get; set; } 33 } 34 public class ApiCustomExceptionMiddleWare 35 { 36 private RequestDelegate _next; 37 private ApiCustomExceptionMiddleWareOption _option; 38 private IDictionary<int, string> _exceptionStatusCodeDic; 39 40 public ApiCustomExceptionMiddleWare(RequestDelegate next, ApiCustomExceptionMiddleWareOption option) 41 { 42 _next = next; 43 _option = option; 44 _exceptionStatusCodeDic = new Dictionary<int, string> 45 { 46 { 401, "未授权的请求" }, 47 { 404, "找不到该页面" }, 48 { 403, "访问被拒绝" }, 49 { 500, "服务器发生意外的错误" } 50 //其余状态自行扩展 51 }; 52 } 53 54 public async Task Invoke(HttpContext context) 55 { 56 Exception exception = null; 57 try 58 { 59 await _next(context); 60 } 61 catch (Exception ex) 62 { 63 context.Response.Clear(); 64 context.Response.StatusCode = 200;//手动设置状态码(总是成功) 65 exception = ex; 66 } 67 finally 68 { 69 if (_exceptionStatusCodeDic.ContainsKey(context.Response.StatusCode) && 70 !context.Items.ContainsKey("ExceptionHandled")) 71 { 72 var errorMsg = string.Empty; 73 if (context.Response.StatusCode == 500 && exception != null) 74 { 75 errorMsg = $"{_exceptionStatusCodeDic[context.Response.StatusCode]}\r\n{(exception.InnerException != null ? exception.InnerException.Message : exception.Message)}"; 76 } 77 else 78 { 79 errorMsg = _exceptionStatusCodeDic[context.Response.StatusCode]; 80 } 81 exception = new Exception(errorMsg); 82 } 83 if (exception != null) 84 { 85 var handleType = _option.HandleType; 86 if (handleType == ApiCustomExceptionHandleType.Both) 87 { 88 var requestPath = context.Request.Path; 89 handleType = _option.JsonHandleUrlKeys != null && _option.JsonHandleUrlKeys.Count( 90 k => requestPath.StartsWithSegments(k, StringComparison.CurrentCultureIgnoreCase)) > 0 ? 91 ApiCustomExceptionHandleType.JsonHandle : 92 ApiCustomExceptionHandleType.PageHandle; 93 } 94 95 if (handleType == ApiCustomExceptionHandleType.JsonHandle) 96 await JsonHandle(context, exception); 97 else 98 await PageHandle(context, exception, _option.ErrorHandingPath); 99 } 100 } 101 } 102 private Jwt.Gateway.Models.ApiResponse GetApiResponse(Exception ex) 103 { 104 return new Jwt.Gateway.Models.ApiResponse() { status = 0, message = ex.Message }; 105 } 106 private async Task JsonHandle(HttpContext context, Exception ex) 107 { 108 var apiResponse = GetApiResponse(ex); 109 var serialzeStr = Newtonsoft.Json.JsonConvert.SerializeObject(apiResponse); 110 context.Response.ContentType = "application/json"; 111 await context.Response.WriteAsync(serialzeStr, System.Text.Encoding.UTF8); 112 } 113 private async Task PageHandle(HttpContext context, Exception ex, PathString path) 114 { 115 context.Items.Add("Exception", ex); 116 var originPath = context.Request.Path; 117 context.Request.Path = path; 118 try 119 { 120 await _next(context); 121 } 122 catch { } 123 finally 124 { 125 context.Request.Path = originPath; 126 } 127 } 128 } 129 public static class ApiCustomExceptionMiddleWareExtensions 130 { 131 public static IApplicationBuilder UseApiCustomException(this IApplicationBuilder app, ApiCustomExceptionMiddleWareOption option) 132 { 133 return app.UseMiddleware<ApiCustomExceptionMiddleWare>(option); 134 } 135 } 136 }
配置相关
appsettings.json
算法'HS256'要求SecurityKey.KeySize大于'128'位,所以JwtSecurityKey可不要太短了哦。
1 { 2 "Urls": "http://localhost:60000", 3 "AllowedHosts": "*", 4 "JwtSecurityKey": "areyouokhhhhhhhhhhhhhhhhhhhhhhhhhhh", 5 "JwtTokenIssuer": "Jwt.Gateway", 6 "JwtTokenAudience": "App" 7 }
Startup.cs
关于JWT的配置可以在通过JwtBearerOptions加入一些自己的事件处理逻辑,共有4个事件可供调用:OnAuthenticationFailed,OnMessageReceived,OnTokenValidated,OnChallenge, 本示例中是在OnTokenValidated中插入Token黑名单的校验逻辑。黑名单应该是Jwt应用场景中主动使Token过期的主流做法了。
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Threading.Tasks; 5 using Microsoft.AspNetCore.Builder; 6 using Microsoft.AspNetCore.Hosting; 7 using Microsoft.AspNetCore.Http; 8 using Jwt.Gateway.MiddleWares; 9 using Microsoft.Extensions.DependencyInjection; 10 11 namespace Jwt.Gateway 12 { 13 public class Startup 14 { 15 private readonly Microsoft.Extensions.Configuration.IConfiguration _configuration; 16 17 public Startup(Microsoft.Extensions.Configuration.IConfiguration configuration) 18 { 19 _configuration = configuration; 20 } 21 22 public void ConfigureServices(IServiceCollection services) 23 { 24 services.AddAuthentication(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerDefaults.AuthenticationScheme) 25 .AddJwtBearer(options => { 26 options.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents 27 { 28 /*OnMessageReceived = context => 29 { 30 context.Token = context.Request.Query["access_token"]; 31 return Task.CompletedTask; 32 },*/ 33 OnTokenValidated = context => 34 { 35 var token = ((System.IdentityModel.Tokens.Jwt.JwtSecurityToken)context.SecurityToken).RawData; 36 if (InBlacklist(token)) 37 { 38 context.Fail("token in blacklist"); 39 } 40 return Task.CompletedTask; 41 } 42 }; 43 options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters 44 { 45 ValidateIssuer = true, 46 ValidateAudience = true, 47 ValidateLifetime = true, 48 ValidateIssuerSigningKey = true, 49 ValidAudience = _configuration["JwtTokenAudience"], 50 ValidIssuer = _configuration["JwtTokenIssuer"], 51 IssuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(_configuration["JwtSecurityKey"])) 52 }; 53 }); 54 services.AddMvc().AddJsonOptions(option=> { 55 option.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm:ss.fff"; 56 }); 57 } 58 59 public void Configure(IApplicationBuilder app, IHostingEnvironment env) 60 { 61 if (env.IsDevelopment()) 62 { 63 app.UseDeveloperExceptionPage(); 64 } 65 66 app.UseApiCustomException(new ApiCustomExceptionMiddleWareOption( 67 handleType: ApiCustomExceptionHandleType.Both, 68 jsonHandleUrlKeys: new PathString[] { "/api" }, 69 errorHandingPath: "/home/error")); 70 71 app.UseAuthentication(); 72 73 app.UseMvc(); 74 } 75 76 77 78 bool InBlacklist(string token) 79 { 80 //code: 实际项目中应该查询数据库或配置文件进行比对 81 82 return false; 83 } 84 85 86 } 87 }
Program.cs
1 using System; 2 using System.Collections.Generic; 3 using System.IO; 4 using System.Linq; 5 using System.Threading.Tasks; 6 using Microsoft.AspNetCore; 7 using Microsoft.AspNetCore.Hosting; 8 using Microsoft.Extensions.Configuration; 9 using Microsoft.Extensions.Logging; 10 11 namespace Jwt.Gateway 12 { 13 public class Program 14 { 15 public static void Main(string[] args) 16 { 17 BuildWebHost(args).Run(); 18 } 19 20 public static IWebHost BuildWebHost(string[] args) 21 { 22 var config = new ConfigurationBuilder() 23 .SetBasePath(Directory.GetCurrentDirectory()) 24 .AddJsonFile("appsettings.json", optional: true) 25 .Build(); 26 27 return WebHost.CreateDefaultBuilder(args) 28 .UseKestrel() 29 .UseConfiguration(config) 30 .UseStartup<Startup>() 31 .Build(); 32 } 33 } 34 }
运行截图
[运行截图-获取Token]
[运行截图-配置Fiddler调用接口获取数据]
[运行截图-获取到数据]
如果Token校验失败将会返回401错误!
如果你发现有错误,请善意指出,谢谢!
来源:https://www.cnblogs.com/ruzi/p/10493501.html