diff options
author | crupest <crupest@outlook.com> | 2019-02-14 23:05:04 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2019-02-14 23:05:04 +0800 |
commit | 3c140656ebe6ed34dda9356a01dbff205651e641 (patch) | |
tree | 8b8ca7331c9510b897042737a5cbbc0f77b1b736 | |
parent | de90f0413553a23f8ebba1343c6e96c63e0c9748 (diff) | |
download | timeline-3c140656ebe6ed34dda9356a01dbff205651e641.tar.gz timeline-3c140656ebe6ed34dda9356a01dbff205651e641.tar.bz2 timeline-3c140656ebe6ed34dda9356a01dbff205651e641.zip |
Develop user token interface.
-rw-r--r-- | Timeline.Tests/AuthorizationUnitTest.cs | 47 | ||||
-rw-r--r-- | Timeline.Tests/Helpers/Authentication/AuthenticationHttpClientExtensions.cs | 41 | ||||
-rw-r--r-- | Timeline.Tests/JwtTokenUnitTest.cs | 86 | ||||
-rw-r--r-- | Timeline/Controllers/UserController.cs | 36 | ||||
-rw-r--r-- | Timeline/Controllers/UserTestController.cs (renamed from Timeline/Controllers/TestController.cs) | 16 | ||||
-rw-r--r-- | Timeline/Entities/User.cs | 15 | ||||
-rw-r--r-- | Timeline/Formatters/StringInputFormatter.cs | 32 | ||||
-rw-r--r-- | Timeline/Services/JwtService.cs | 74 | ||||
-rw-r--r-- | Timeline/Startup.cs | 9 |
9 files changed, 305 insertions, 51 deletions
diff --git a/Timeline.Tests/AuthorizationUnitTest.cs b/Timeline.Tests/AuthorizationUnitTest.cs index e9e86c8e..1566f2ac 100644 --- a/Timeline.Tests/AuthorizationUnitTest.cs +++ b/Timeline.Tests/AuthorizationUnitTest.cs @@ -1,12 +1,12 @@ using Microsoft.AspNetCore.Mvc.Testing; using Newtonsoft.Json; using System; -using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; using Timeline.Controllers; using Timeline.Tests.Helpers; +using Timeline.Tests.Helpers.Authentication; using Xunit; using Xunit.Abstractions; @@ -14,6 +14,10 @@ namespace Timeline.Tests { public class AuthorizationUnitTest : IClassFixture<WebApplicationFactory<Startup>> { + private const string NeedAuthorizeUrl = "api/test/User/NeedAuthorize"; + private const string BothUserAndAdminUrl = "api/test/User/BothUserAndAdmin"; + private const string OnlyAdminUrl = "api/test/User/OnlyAdmin"; + private readonly WebApplicationFactory<Startup> _factory; public AuthorizationUnitTest(WebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper) @@ -26,41 +30,18 @@ namespace Timeline.Tests { using (var client = _factory.CreateDefaultClient()) { - var response = await client.GetAsync("/api/Test/Action1"); + var response = await client.GetAsync(NeedAuthorizeUrl); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } - private static async Task<string> Login(HttpClient client, string username, string password) - { - var response = await client.PostAsJsonAsync("/api/User/LogIn", new UserController.UserCredentials { Username = username, Password = password }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var loginInfo = JsonConvert.DeserializeObject<UserController.LoginInfo>(await response.Content.ReadAsStringAsync()); - - return loginInfo.Token; - } - - private static async Task<HttpResponseMessage> GetWithAuthentication(HttpClient client, string path, string token) - { - var request = new HttpRequestMessage - { - RequestUri = new Uri(client.BaseAddress, path), - Method = HttpMethod.Get - }; - request.Headers.Add("Authorization", "Bearer " + token); - - return await client.SendAsync(request); - } - [Fact] public async Task AuthenticationTest() { using (var client = _factory.CreateDefaultClient()) { - var token = await Login(client, "user", "user"); - var response = await GetWithAuthentication(client, "/api/Test/Action1", token); + var token = (await client.CreateUserTokenAsync("user", "user")).Token; + var response = await client.SendWithAuthenticationAsync(token, NeedAuthorizeUrl); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } } @@ -70,10 +51,10 @@ namespace Timeline.Tests { using (var client = _factory.CreateDefaultClient()) { - var token = await Login(client, "user", "user"); - var response1 = await GetWithAuthentication(client, "/api/Test/Action2", token); + var token = (await client.CreateUserTokenAsync("user", "user")).Token; + var response1 = await client.SendWithAuthenticationAsync(token, BothUserAndAdminUrl); Assert.Equal(HttpStatusCode.OK, response1.StatusCode); - var response2 = await GetWithAuthentication(client, "/api/Test/Action3", token); + var response2 = await client.SendWithAuthenticationAsync(token, OnlyAdminUrl); Assert.Equal(HttpStatusCode.Forbidden, response2.StatusCode); } } @@ -83,10 +64,10 @@ namespace Timeline.Tests { using (var client = _factory.CreateDefaultClient()) { - var token = await Login(client, "admin", "admin"); - var response1 = await GetWithAuthentication(client, "/api/Test/Action2", token); + var token = (await client.CreateUserTokenAsync("admin", "admin")).Token; + var response1 = await client.SendWithAuthenticationAsync(token, BothUserAndAdminUrl); Assert.Equal(HttpStatusCode.OK, response1.StatusCode); - var response2 = await GetWithAuthentication(client, "/api/Test/Action3", token); + var response2 = await client.SendWithAuthenticationAsync(token, OnlyAdminUrl); Assert.Equal(HttpStatusCode.OK, response2.StatusCode); } } diff --git a/Timeline.Tests/Helpers/Authentication/AuthenticationHttpClientExtensions.cs b/Timeline.Tests/Helpers/Authentication/AuthenticationHttpClientExtensions.cs new file mode 100644 index 00000000..a4cb8c65 --- /dev/null +++ b/Timeline.Tests/Helpers/Authentication/AuthenticationHttpClientExtensions.cs @@ -0,0 +1,41 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Timeline.Controllers; +using Xunit; + +namespace Timeline.Tests.Helpers.Authentication +{ + public static class AuthenticationHttpClientExtensions + { + private const string CreateTokenUrl = "/api/User/CreateToken"; + + public static async Task<UserController.CreateTokenResult> CreateUserTokenAsync(this HttpClient client, string username, string password) + { + var response = await client.PostAsJsonAsync(CreateTokenUrl, new UserController.UserCredentials { Username = username, Password = password }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = JsonConvert.DeserializeObject<UserController.CreateTokenResult>(await response.Content.ReadAsStringAsync()); + + return result; + } + + public static async Task<HttpResponseMessage> SendWithAuthenticationAsync(this HttpClient client, string token, string path, Action<HttpRequestMessage> requestBuilder = null) + { + var request = new HttpRequestMessage + { + RequestUri = new Uri(client.BaseAddress, path), + }; + request.Headers.Add("Authorization", "Bearer " + token); + + requestBuilder?.Invoke(request); + + return await client.SendAsync(request); + } + } +} diff --git a/Timeline.Tests/JwtTokenUnitTest.cs b/Timeline.Tests/JwtTokenUnitTest.cs new file mode 100644 index 00000000..e55bc82c --- /dev/null +++ b/Timeline.Tests/JwtTokenUnitTest.cs @@ -0,0 +1,86 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Timeline.Controllers; +using Timeline.Services; +using Timeline.Tests.Helpers; +using Timeline.Tests.Helpers.Authentication; +using Xunit; +using Xunit.Abstractions; + +namespace Timeline.Tests +{ + public class JwtTokenUnitTest : IClassFixture<WebApplicationFactory<Startup>> + { + private const string ValidateTokenUrl = "/api/User/ValidateToken"; + + private readonly WebApplicationFactory<Startup> _factory; + + public JwtTokenUnitTest(WebApplicationFactory<Startup> factory, ITestOutputHelper outputHelper) + { + _factory = factory.WithTestConfig(outputHelper); + } + + [Fact] + public async void ValidateToken_BadTokenTest() + { + using (var client = _factory.CreateDefaultClient()) + { + var response = await client.PostAsync(ValidateTokenUrl, new StringContent("bad token hahaha", Encoding.UTF8, "text/plain")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var validationInfo = JsonConvert.DeserializeObject<TokenValidationResult>(await response.Content.ReadAsStringAsync()); + + Assert.False(validationInfo.IsValid); + Assert.Null(validationInfo.UserInfo); + } + } + + [Fact] + public async void ValidateToken_PlainTextGoodTokenTest() + { + using (var client = _factory.CreateDefaultClient()) + { + var createTokenResult = await client.CreateUserTokenAsync("admin", "admin"); + + var response = await client.PostAsync(ValidateTokenUrl, new StringContent(createTokenResult.Token, Encoding.UTF8, "text/plain")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = JsonConvert.DeserializeObject<TokenValidationResult>(await response.Content.ReadAsStringAsync()); + + Assert.True(result.IsValid); + Assert.NotNull(result.UserInfo); + Assert.Equal(createTokenResult.UserInfo.Username, result.UserInfo.Username); + Assert.Equal(createTokenResult.UserInfo.Roles, result.UserInfo.Roles); + } + } + + [Fact] + public async void ValidateToken_JsonGoodTokenTest() + { + using (var client = _factory.CreateDefaultClient()) + { + var createTokenResult = await client.CreateUserTokenAsync("admin", "admin"); + + var response = await client.PostAsJsonAsync(ValidateTokenUrl, new UserController.TokenValidationRequest { Token = createTokenResult.Token }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = JsonConvert.DeserializeObject<TokenValidationResult>(await response.Content.ReadAsStringAsync()); + + Assert.True(result.IsValid); + Assert.NotNull(result.UserInfo); + Assert.Equal(createTokenResult.UserInfo.Username, result.UserInfo.Username); + Assert.Equal(createTokenResult.UserInfo.Roles, result.UserInfo.Roles); + } + } + } +} diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index 9d6970e7..1ffed22b 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -1,6 +1,9 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using System.IO; +using Timeline.Entities; using Timeline.Services; namespace Timeline.Controllers @@ -20,10 +23,15 @@ namespace Timeline.Controllers public string Password { get; set; } } - public class LoginInfo + public class CreateTokenResult + { + public string Token { get; set; } + public UserInfo UserInfo { get; set; } + } + + public class TokenValidationRequest { public string Token { get; set; } - public string[] Roles { get; set; } } private readonly IUserService _userService; @@ -39,7 +47,7 @@ namespace Timeline.Controllers [HttpPost("[action]")] [AllowAnonymous] - public ActionResult<LoginInfo> LogIn([FromBody] UserCredentials credentials) + public ActionResult<CreateTokenResult> CreateToken([FromBody] UserCredentials credentials) { var user = _userService.Authenticate(credentials.Username, credentials.Password); @@ -50,13 +58,31 @@ namespace Timeline.Controllers _logger.LogInformation(LoggingEventIds.LogInSucceeded, "Login with username: {} succeeded.", credentials.Username); - var result = new LoginInfo + var result = new CreateTokenResult { Token = _jwtService.GenerateJwtToken(user), - Roles = user.Roles + UserInfo = user.GetUserInfo() }; return Ok(result); } + + [HttpPost("[action]")] + [Consumes("text/plain")] + [AllowAnonymous] + public ActionResult<TokenValidationResult> ValidateToken([FromBody] string token) + { + var result = _jwtService.ValidateJwtToken(token); + return Ok(result); + } + + [HttpPost("[action]")] + [Consumes("application/json")] + [AllowAnonymous] + public ActionResult<TokenValidationResult> ValidateToken([FromBody] TokenValidationRequest request) + { + var result = _jwtService.ValidateJwtToken(request.Token); + return Ok(result); + } } } diff --git a/Timeline/Controllers/TestController.cs b/Timeline/Controllers/UserTestController.cs index 1563830c..7fb6850b 100644 --- a/Timeline/Controllers/TestController.cs +++ b/Timeline/Controllers/UserTestController.cs @@ -7,28 +7,28 @@ using Microsoft.AspNetCore.Mvc; namespace Timeline.Controllers { - [Route("api/[controller]")] - public class TestController : Controller + [Route("api/test/User")] + public class UserTestController : Controller { [HttpGet("[action]")] [Authorize] - public string Action1() + public ActionResult NeedAuthorize() { - return "test"; + return Ok(); } [HttpGet("[action]")] [Authorize(Roles = "User,Admin")] - public string Action2() + public ActionResult BothUserAndAdmin() { - return "test"; + return Ok(); } [HttpGet("[action]")] [Authorize(Roles = "Admin")] - public string Action3() + public ActionResult OnlyAdmin() { - return "test"; + return Ok(); } } } diff --git a/Timeline/Entities/User.cs b/Timeline/Entities/User.cs index 3f7e9ac6..50463b57 100644 --- a/Timeline/Entities/User.cs +++ b/Timeline/Entities/User.cs @@ -11,5 +11,20 @@ namespace Timeline.Entities public string Username { get; set; } public string Password { get; set; } public string[] Roles { get; set; } + + public UserInfo GetUserInfo() + { + return new UserInfo + { + Username = this.Username, + Roles = this.Roles + }; + } + } + + public class UserInfo + { + public string Username { get; set; } + public string[] Roles { get; set; } } } diff --git a/Timeline/Formatters/StringInputFormatter.cs b/Timeline/Formatters/StringInputFormatter.cs new file mode 100644 index 00000000..f2475137 --- /dev/null +++ b/Timeline/Formatters/StringInputFormatter.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Timeline.Formatters +{ + public class StringInputFormatter : TextInputFormatter + { + public StringInputFormatter() + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/plain")); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context, Encoding effectiveEncoding) + { + var request = context.HttpContext.Request; + using (var reader = new StreamReader(request.Body, effectiveEncoding)) + { + var stringContent = reader.ReadToEnd(); + return InputFormatterResult.SuccessAsync(stringContent); + } + } + } +} diff --git a/Timeline/Services/JwtService.cs b/Timeline/Services/JwtService.cs index 1b465dd9..a01f3f2b 100644 --- a/Timeline/Services/JwtService.cs +++ b/Timeline/Services/JwtService.cs @@ -1,17 +1,22 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using System; -using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Text; -using System.Threading.Tasks; using Timeline.Configs; using Timeline.Entities; namespace Timeline.Services { + public class TokenValidationResult + { + public bool IsValid { get; set; } + public UserInfo UserInfo { get; set; } + } + public interface IJwtService { /// <summary> @@ -21,16 +26,34 @@ namespace Timeline.Services /// <param name="user">The user to generate token.</param> /// <returns>The generated token or null if <paramref name="user"/> is null.</returns> string GenerateJwtToken(User user); + + /// <summary> + /// Validate a JWT token. + /// Return null is <paramref name="token"/> is null. + /// If token is invalid, return a <see cref="TokenValidationResult"/> with + /// <see cref="TokenValidationResult.IsValid"/> set to false and + /// <see cref="TokenValidationResult.UserInfo"/> set to null. + /// If token is valid, return a <see cref="TokenValidationResult"/> with + /// <see cref="TokenValidationResult.IsValid"/> set to true and + /// <see cref="TokenValidationResult.UserInfo"/> filled with the user info + /// in the token. + /// </summary> + /// <param name="token">The token string to validate.</param> + /// <returns>Null if <paramref name="token"/> is null. Or the result.</returns> + TokenValidationResult ValidateJwtToken(string token); + } public class JwtService : IJwtService { private readonly IOptionsMonitor<JwtConfig> _jwtConfig; private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler(); + private readonly ILogger<JwtService> _logger; - public JwtService(IOptionsMonitor<JwtConfig> jwtConfig) + public JwtService(IOptionsMonitor<JwtConfig> jwtConfig, ILogger<JwtService> logger) { _jwtConfig = jwtConfig; + _logger = logger; } public string GenerateJwtToken(User user) @@ -42,6 +65,7 @@ namespace Timeline.Services var identity = new ClaimsIdentity(); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); + identity.AddClaim(new Claim(identity.NameClaimType, user.Username)); identity.AddClaims(user.Roles.Select(role => new Claim(identity.RoleClaimType, role))); var tokenDescriptor = new SecurityTokenDescriptor() @@ -60,5 +84,47 @@ namespace Timeline.Services return tokenString; } + + + public TokenValidationResult ValidateJwtToken(string token) + { + if (token == null) + return null; + + var config = _jwtConfig.CurrentValue; + + try + { + var principal = _tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ValidIssuer = config.Issuer, + ValidAudience = config.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)) + }, out SecurityToken validatedToken); + + var identity = principal.Identity as ClaimsIdentity; + + var userInfo = new UserInfo + { + Username = identity.FindAll(identity.NameClaimType).Select(claim => claim.Value).Single(), + Roles = identity.FindAll(identity.RoleClaimType).Select(claim => claim.Value).ToArray() + }; + + return new TokenValidationResult + { + IsValid = true, + UserInfo = userInfo + }; + } + catch (Exception e) + { + _logger.LogInformation(e, "Token validation failed! Token is {} .", token); + return new TokenValidationResult { IsValid = false }; + } + } } } diff --git a/Timeline/Startup.cs b/Timeline/Startup.cs index 2a0f437f..6381a58a 100644 --- a/Timeline/Startup.cs +++ b/Timeline/Startup.cs @@ -10,6 +10,10 @@ using System.Text; using Timeline.Configs; using Timeline.Services; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc.Formatters; +using System; +using System.Threading.Tasks; +using Timeline.Formatters; namespace Timeline { @@ -25,7 +29,10 @@ namespace Timeline // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + services.AddMvc(options => + { + options.InputFormatters.Add(new StringInputFormatter()); + }).SetCompatibilityVersion(CompatibilityVersion.Version_2_2); // In production, the Angular files will be served from this directory services.AddSpaStaticFiles(configuration => |