aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2019-02-14 23:05:04 +0800
committercrupest <crupest@outlook.com>2019-02-14 23:05:04 +0800
commit3c140656ebe6ed34dda9356a01dbff205651e641 (patch)
tree8b8ca7331c9510b897042737a5cbbc0f77b1b736
parentde90f0413553a23f8ebba1343c6e96c63e0c9748 (diff)
downloadtimeline-3c140656ebe6ed34dda9356a01dbff205651e641.tar.gz
timeline-3c140656ebe6ed34dda9356a01dbff205651e641.tar.bz2
timeline-3c140656ebe6ed34dda9356a01dbff205651e641.zip
Develop user token interface.
-rw-r--r--Timeline.Tests/AuthorizationUnitTest.cs47
-rw-r--r--Timeline.Tests/Helpers/Authentication/AuthenticationHttpClientExtensions.cs41
-rw-r--r--Timeline.Tests/JwtTokenUnitTest.cs86
-rw-r--r--Timeline/Controllers/UserController.cs36
-rw-r--r--Timeline/Controllers/UserTestController.cs (renamed from Timeline/Controllers/TestController.cs)16
-rw-r--r--Timeline/Entities/User.cs15
-rw-r--r--Timeline/Formatters/StringInputFormatter.cs32
-rw-r--r--Timeline/Services/JwtService.cs74
-rw-r--r--Timeline/Startup.cs9
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 =>