From 5e71226edf67340be4a59dce0edf4e6b8b5a7622 Mon Sep 17 00:00:00 2001 From: YuukanOO Date: Thu, 29 Apr 2021 15:44:39 +0200 Subject: [PATCH] Ajout authentification sur Blazor --- Apps/Api/Controllers/CommentsController.cs | 1 + Apps/Api/Controllers/LinksController.cs | 1 + Apps/Api/swagger.json | 20 + Apps/Client/App.razor | 26 +- Apps/Client/Client.csproj | 1 + Apps/Client/HNApiClient.cs | 424 +++++++++++++++++++++ Apps/Client/JwtAuthStateProvider.cs | 104 +++++ Apps/Client/LocalStorage.cs | 25 ++ Apps/Client/Pages/Detail.razor | 9 +- Apps/Client/Pages/Login.razor | 44 +++ Apps/Client/Pages/PublishLink.razor | 1 + Apps/Client/Program.cs | 6 + Apps/Client/Shared/LinkItem.razor | 2 +- Apps/Client/Shared/NavMenu.razor | 36 +- Apps/Client/_Imports.razor | 2 + Apps/Client/wwwroot/index.html | 8 + Apps/Website/hn.db-wal | Bin 399672 -> 457352 bytes 17 files changed, 692 insertions(+), 18 deletions(-) create mode 100644 Apps/Client/JwtAuthStateProvider.cs create mode 100644 Apps/Client/LocalStorage.cs create mode 100644 Apps/Client/Pages/Login.razor diff --git a/Apps/Api/Controllers/CommentsController.cs b/Apps/Api/Controllers/CommentsController.cs index aeccb36..1f18a1b 100644 --- a/Apps/Api/Controllers/CommentsController.cs +++ b/Apps/Api/Controllers/CommentsController.cs @@ -39,6 +39,7 @@ namespace Api.Controllers [HttpPost] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status201Created)] public IActionResult Create(PublishCommentCommand cmd) { diff --git a/Apps/Api/Controllers/LinksController.cs b/Apps/Api/Controllers/LinksController.cs index 7e00dcc..26e04a9 100644 --- a/Apps/Api/Controllers/LinksController.cs +++ b/Apps/Api/Controllers/LinksController.cs @@ -63,6 +63,7 @@ namespace Api.Controllers /// [HttpPost] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status201Created)] public IActionResult Create(PublishLinkCommand cmd) { diff --git a/Apps/Api/swagger.json b/Apps/Api/swagger.json index da3c7a6..415d93b 100644 --- a/Apps/Api/swagger.json +++ b/Apps/Api/swagger.json @@ -179,6 +179,16 @@ } } }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "201": { "description": "" } @@ -237,6 +247,16 @@ } } }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "201": { "description": "" } diff --git a/Apps/Client/App.razor b/Apps/Client/App.razor index b941644..cadab7f 100644 --- a/Apps/Client/App.razor +++ b/Apps/Client/App.razor @@ -1,10 +1,16 @@ - - - - - - -

Sorry, there's nothing at this address.

-
-
-
+ + + + + +

Oh noes

+
+
+
+ + +

Sorry, there's nothing at this address.

+
+
+
+
\ No newline at end of file diff --git a/Apps/Client/Client.csproj b/Apps/Client/Client.csproj index 2204e7d..69601cd 100644 --- a/Apps/Client/Client.csproj +++ b/Apps/Client/Client.csproj @@ -5,6 +5,7 @@ + diff --git a/Apps/Client/HNApiClient.cs b/Apps/Client/HNApiClient.cs index 3b8fabf..ce7cfd9 100644 --- a/Apps/Client/HNApiClient.cs +++ b/Apps/Client/HNApiClient.cs @@ -15,6 +15,372 @@ namespace Client { using System = global::System; + [System.CodeDom.Compiler.GeneratedCode("NSwag", "13.10.9.0 (NJsonSchema v10.4.1.0 (Newtonsoft.Json v12.0.0.0))")] + public partial class AccountsClient + { + private System.Net.Http.HttpClient _httpClient; + private System.Lazy _settings; + + public AccountsClient(System.Net.Http.HttpClient httpClient) + { + _httpClient = httpClient; + _settings = new System.Lazy(CreateSerializerSettings); + } + + private Newtonsoft.Json.JsonSerializerSettings CreateSerializerSettings() + { + var settings = new Newtonsoft.Json.JsonSerializerSettings(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + protected Newtonsoft.Json.JsonSerializerSettings JsonSerializerSettings { get { return _settings.Value; } } + + partial void UpdateJsonSerializerSettings(Newtonsoft.Json.JsonSerializerSettings settings); + + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + /// A server side error occurred. + public System.Threading.Tasks.Task MeAsync() + { + return MeAsync(System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// A server side error occurred. + public async System.Threading.Tasks.Task MeAsync(System.Threading.CancellationToken cancellationToken) + { + var urlBuilder_ = new System.Text.StringBuilder(); + urlBuilder_.Append("api/accounts/me"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = System.Linq.Enumerable.ToDictionary(response_.Headers, h_ => h_.Key, h_ => h_.Value); + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A server side error occurred. + public System.Threading.Tasks.Task RegisterAsync(RegisterViewModel cmd) + { + return RegisterAsync(cmd, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// A server side error occurred. + public async System.Threading.Tasks.Task RegisterAsync(RegisterViewModel cmd, System.Threading.CancellationToken cancellationToken) + { + if (cmd == null) + throw new System.ArgumentNullException("cmd"); + + var urlBuilder_ = new System.Text.StringBuilder(); + urlBuilder_.Append("api/accounts"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var content_ = new System.Net.Http.StringContent(Newtonsoft.Json.JsonConvert.SerializeObject(cmd, _settings.Value)); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = System.Linq.Enumerable.ToDictionary(response_.Headers, h_ => h_.Key, h_ => h_.Value); + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("A server side error occurred.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 204) + { + return; + } + else + { + var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// A server side error occurred. + public System.Threading.Tasks.Task LoginAsync(LoginViewModel cmd) + { + return LoginAsync(cmd, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// A server side error occurred. + public async System.Threading.Tasks.Task LoginAsync(LoginViewModel cmd, System.Threading.CancellationToken cancellationToken) + { + if (cmd == null) + throw new System.ArgumentNullException("cmd"); + + var urlBuilder_ = new System.Text.StringBuilder(); + urlBuilder_.Append("api/accounts/token"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var content_ = new System.Net.Http.StringContent(Newtonsoft.Json.JsonConvert.SerializeObject(cmd, _settings.Value)); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = System.Linq.Enumerable.ToDictionary(response_.Headers, h_ => h_.Key, h_ => h_.Value); + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("A server side error occurred.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + { + var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + try + { + var typedBody = Newtonsoft.Json.JsonConvert.DeserializeObject(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (Newtonsoft.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + using (var streamReader = new System.IO.StreamReader(responseStream)) + using (var jsonTextReader = new Newtonsoft.Json.JsonTextReader(streamReader)) + { + var serializer = Newtonsoft.Json.JsonSerializer.Create(JsonSerializerSettings); + var typedBody = serializer.Deserialize(jsonTextReader); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (Newtonsoft.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value.GetType().IsArray) + { + var array = System.Linq.Enumerable.OfType((System.Array) value); + return string.Join(",", System.Linq.Enumerable.Select(array, o => ConvertToString(o, cultureInfo))); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + [System.CodeDom.Compiler.GeneratedCode("NSwag", "13.10.9.0 (NJsonSchema v10.4.1.0 (Newtonsoft.Json v12.0.0.0))")] public partial class CommentsClient { @@ -200,6 +566,16 @@ namespace Client throw new ApiException("A server side error occurred.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); } else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("A server side error occurred.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else if (status_ == 201) { return; @@ -488,6 +864,16 @@ namespace Client throw new ApiException("A server side error occurred.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); } else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("A server side error occurred.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else if (status_ == 201) { return; @@ -812,6 +1198,38 @@ namespace Client } + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "10.4.1.0 (Newtonsoft.Json v12.0.0.0)")] + public partial class RegisterViewModel + { + [Newtonsoft.Json.JsonProperty("username", Required = Newtonsoft.Json.Required.Always)] + [System.ComponentModel.DataAnnotations.Required] + public string Username { get; set; } + + [Newtonsoft.Json.JsonProperty("password", Required = Newtonsoft.Json.Required.Always)] + [System.ComponentModel.DataAnnotations.Required] + public string Password { get; set; } + + [Newtonsoft.Json.JsonProperty("confirmPassword", Required = Newtonsoft.Json.Required.Always)] + [System.ComponentModel.DataAnnotations.Required] + public string ConfirmPassword { get; set; } + + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "10.4.1.0 (Newtonsoft.Json v12.0.0.0)")] + public partial class LoginViewModel + { + [Newtonsoft.Json.JsonProperty("username", Required = Newtonsoft.Json.Required.Always)] + [System.ComponentModel.DataAnnotations.Required] + public string Username { get; set; } + + [Newtonsoft.Json.JsonProperty("password", Required = Newtonsoft.Json.Required.Always)] + [System.ComponentModel.DataAnnotations.Required] + public string Password { get; set; } + + } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "10.4.1.0 (Newtonsoft.Json v12.0.0.0)")] @@ -829,6 +1247,9 @@ namespace Client [Newtonsoft.Json.JsonProperty("downvotesCount", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public int DownvotesCount { get; set; } + [Newtonsoft.Json.JsonProperty("createdByUsername", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public string CreatedByUsername { get; set; } + } @@ -867,6 +1288,9 @@ namespace Client [Newtonsoft.Json.JsonProperty("commentsCount", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public int CommentsCount { get; set; } + [Newtonsoft.Json.JsonProperty("createdByUsername", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public string CreatedByUsername { get; set; } + } diff --git a/Apps/Client/JwtAuthStateProvider.cs b/Apps/Client/JwtAuthStateProvider.cs new file mode 100644 index 0000000..5cf159b --- /dev/null +++ b/Apps/Client/JwtAuthStateProvider.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Authorization; + +namespace Client +{ + public class JwtAuthStateProvider : AuthenticationStateProvider + { + private readonly AccountsClient _accounts; + private readonly LocalStorage _storage; + private readonly HttpClient _http; + + public JwtAuthStateProvider(AccountsClient accounts, LocalStorage storage, HttpClient http) + { + _accounts = accounts; + _storage = storage; + _http = http; + } + + public override Task GetAuthenticationStateAsync() + { + return AuthenticationStateFromCurrentToken(); + } + + public async Task TryLoginAsync(LoginViewModel cmd) + { + var token = await _accounts.LoginAsync(cmd); + await _storage.Save("token", token); + + NotifyAuthenticationStateChanged(AuthenticationStateFromCurrentToken()); + } + + public async Task Logout() + { + await _storage.Save("token", string.Empty); + NotifyAuthenticationStateChanged(AuthenticationStateFromCurrentToken()); + } + + private async Task AuthenticationStateFromCurrentToken() + { + var token = await _storage.Get("token"); + + if (string.IsNullOrWhiteSpace(token)) + { + _http.DefaultRequestHeaders.Authorization = null; + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + } + + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var claims = ParseClaimsFromJwt(token); + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt", "unique_name", null)); + return new AuthenticationState(principal); + } + + private IEnumerable ParseClaimsFromJwt(string jwt) + { + var claims = new List(); + var payload = jwt.Split('.')[1]; + var jsonBytes = ParseBase64WithoutPadding(payload); + var keyValuePairs = JsonSerializer.Deserialize>(jsonBytes); + + keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles); + + if (roles != null) + { + if (roles.ToString().Trim().StartsWith("[")) + { + var parsedRoles = JsonSerializer.Deserialize(roles.ToString()); + + foreach (var parsedRole in parsedRoles) + { + claims.Add(new Claim(ClaimTypes.Role, parsedRole)); + } + } + else + { + claims.Add(new Claim(ClaimTypes.Role, roles.ToString())); + } + + keyValuePairs.Remove(ClaimTypes.Role); + } + + claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()))); + return claims; + } + + private byte[] ParseBase64WithoutPadding(string base64) + { + switch (base64.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + return Convert.FromBase64String(base64); + } + } +} \ No newline at end of file diff --git a/Apps/Client/LocalStorage.cs b/Apps/Client/LocalStorage.cs new file mode 100644 index 0000000..1d86d11 --- /dev/null +++ b/Apps/Client/LocalStorage.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Client +{ + public class LocalStorage + { + private readonly IJSRuntime _js; + + public LocalStorage(IJSRuntime js) + { + _js = js; + } + + public async Task Save(string key, string value) + { + await _js.InvokeVoidAsync("setLocalItem", key, value); + } + + public async Task Get(string key) + { + return await _js.InvokeAsync("getLocalItem", key); + } + } +} \ No newline at end of file diff --git a/Apps/Client/Pages/Detail.razor b/Apps/Client/Pages/Detail.razor index 947cec7..fda358a 100644 --- a/Apps/Client/Pages/Detail.razor +++ b/Apps/Client/Pages/Detail.razor @@ -32,7 +32,14 @@ else } - + + + + + +

You should log in to comment!

+
+
} @code { diff --git a/Apps/Client/Pages/Login.razor b/Apps/Client/Pages/Login.razor new file mode 100644 index 0000000..a6f2627 --- /dev/null +++ b/Apps/Client/Pages/Login.razor @@ -0,0 +1,44 @@ +@page "/login" +@inject NavigationManager Navigation +@inject AuthenticationStateProvider Authentication + + + +<h1>Login</h1> + +@if (_loginFailed) +{ + <p>Could not log you in :'(</p> +} + +<EditForm Model="_model" OnValidSubmit="TryLogin"> + <DataAnnotationsValidator /> + <ValidationSummary /> + + <label for="username">Username</label> + <InputText id="username" @bind-Value="_model.Username" /> + + <label for="password">Password</label> + <InputText id="password" type="password" @bind-Value="_model.Password" /> + + <button type="submit">Log me in!</button> +</EditForm> + +@code { + private LoginViewModel _model = new LoginViewModel(); + private bool _loginFailed; + + private async Task TryLogin() + { + _loginFailed = false; + try + { + await ((JwtAuthStateProvider)Authentication).TryLoginAsync(_model); + Navigation.NavigateTo("/"); + } + catch + { + _loginFailed = true; + } + } +} \ No newline at end of file diff --git a/Apps/Client/Pages/PublishLink.razor b/Apps/Client/Pages/PublishLink.razor index 70074fd..aa04036 100644 --- a/Apps/Client/Pages/PublishLink.razor +++ b/Apps/Client/Pages/PublishLink.razor @@ -3,6 +3,7 @@ @inject NavigationManager Navigation @inject NotificationManager Notification @using System.ComponentModel.DataAnnotations +@attribute [Authorize] <h1>Publish a new link!</h1> diff --git a/Apps/Client/Program.cs b/Apps/Client/Program.cs index d3054c4..1a5c5e4 100644 --- a/Apps/Client/Program.cs +++ b/Apps/Client/Program.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Components.Authorization; namespace Client { @@ -17,9 +18,14 @@ namespace Client var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add<App>("#app"); + builder.Services.AddAuthorizationCore(); + builder.Services.AddScoped<AuthenticationStateProvider, JwtAuthStateProvider>(); + builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.Configuration["BaseUrl"]) }); builder.Services.AddScoped<LinksClient>(); builder.Services.AddScoped<CommentsClient>(); + builder.Services.AddScoped<AccountsClient>(); + builder.Services.AddSingleton<LocalStorage>(); builder.Services.AddSingleton<NotificationManager>(); await builder.Build().RunAsync(); diff --git a/Apps/Client/Shared/LinkItem.razor b/Apps/Client/Shared/LinkItem.razor index 65868dd..b111a45 100644 --- a/Apps/Client/Shared/LinkItem.razor +++ b/Apps/Client/Shared/LinkItem.razor @@ -2,7 +2,7 @@ <h2>@Item.Url</h2> <p> <NavLink href="@($"/links/{Item.Id}")">Show</NavLink> - - Published at @Item.CreatedAt.DateTime.ToLongDateString() + - Published at @Item.CreatedAt.DateTime.ToLongDateString() by @Item.CreatedByUsername - 🗨 @Item.CommentsCount - 👍 @Item.UpvotesCount / 👎 @Item.DownvotesCount </p> diff --git a/Apps/Client/Shared/NavMenu.razor b/Apps/Client/Shared/NavMenu.razor index 38a28fd..a03f1c7 100644 --- a/Apps/Client/Shared/NavMenu.razor +++ b/Apps/Client/Shared/NavMenu.razor @@ -1,4 +1,6 @@ -<div class="top-row pl-4 navbar navbar-dark"> +@inject AuthenticationStateProvider Authentication + +<div class="top-row pl-4 navbar navbar-dark"> <a class="navbar-brand" href="">Client</a> <button class="navbar-toggler" @onclick="ToggleNavMenu"> <span class="navbar-toggler-icon"></span> @@ -12,11 +14,28 @@ <span class="oi oi-home" aria-hidden="true"></span> Home </NavLink> </li> - <li class="nav-item px-3"> - <NavLink class="nav-link" href="links/new"> - <span class="oi oi-plus" aria-hidden="true"></span> Publish a new link - </NavLink> - </li> + <AuthorizeView> + <Authorized> + <li class="nav-item px-3"> + <NavLink class="nav-link" href="links/new"> + <span class="oi oi-plus" aria-hidden="true"></span> Publish a new link + </NavLink> + </li> + <li class="nav-item px-3"> + <button @onclick="Logout"> + Sign out + </button> + </li> + </Authorized> + <NotAuthorized> + <li class="nav-item px-3"> + <NavLink class="nav-link" href="login"> + <span class="oi oi-plus" aria-hidden="true"></span> Sign in + </NavLink> + </li> + </NotAuthorized> + </AuthorizeView> + <li class="nav-item px-3"> <NavLink class="nav-link" href="counter"> <span class="oi oi-plus" aria-hidden="true"></span> Counter @@ -39,4 +58,9 @@ { collapseNavMenu = !collapseNavMenu; } + + private Task Logout() + { + return ((JwtAuthStateProvider)Authentication).Logout(); + } } diff --git a/Apps/Client/_Imports.razor b/Apps/Client/_Imports.razor index c07d903..5e4d444 100644 --- a/Apps/Client/_Imports.razor +++ b/Apps/Client/_Imports.razor @@ -8,3 +8,5 @@ @using Microsoft.JSInterop @using Client @using Client.Shared +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Authorization \ No newline at end of file diff --git a/Apps/Client/wwwroot/index.html b/Apps/Client/wwwroot/index.html index 889c804..bfe5e2e 100644 --- a/Apps/Client/wwwroot/index.html +++ b/Apps/Client/wwwroot/index.html @@ -26,6 +26,14 @@ function setTitle(title) { document.title = title; } + + function setLocalItem(key, value) { + localStorage.setItem(key, value); + } + + function getLocalItem(key) { + return localStorage.getItem(key); + } </script> <script src="_framework/blazor.webassembly.js"></script> </body> diff --git a/Apps/Website/hn.db-wal b/Apps/Website/hn.db-wal index b112f61d1d85f0abbab1140455ca1810a4a8ebbd..6f31e0f3e88814d03701af2ca781a5224d2e758a 100644 GIT binary patch delta 1545 zcmchWTSyd97{}My&M4Z>EU9@7BotTT?4CJuX3osg%bdMLiPaiI_25I4T4`>fVF_W@ z7ExJ5mnkYJ3W-2%+sL4opesQ>MPw8`Rum+3F?*?*^<}#}OcA|*@cqBv|M%UvTE6d7 z)y`C2ic5J@vr^z1uT@y*@`1mymliiYYI>Jn=<#@fNIc?Gaa7zZwumI1m@iuGWgw6T ztmFd_um-<?1C>MO=-CsG7hd%_^S<34agUfU>=gvx?sQ^{Z_z*j0A|E&Qka5KoH1y~ zN)(b9lA)nf0~$<|3^h&-$_$LK1QCH)uaV?1LSYF9F;%dpDAv4s5UB1BheDmnwQD-p zC%b#XDne4Ib3<20cc`nicPyCCb6n-f^KrON-gY=AKC$DEiY-q3hJe@1a}xzv$IJN( z)@us<1xL@{9T+dYH=Xex$%s}*8ECM2R&e#Dz`od1wp@Ogd3D{tWcB3_o8NC5&JNYN zS;1M8D}h$hSeN&9ddFni;l~LR9<fuHNGBc%jRSs;H;W^t$tc1sY7m<hW{@CBhD^61 zMk9zy5lNS#SXtk?{U>@jjHMu@lpqZ|^lEF}gtE@GgNp^^Y5%vOV~cBNj&ZY}vud`1 z1r2;IlE$4SnK$1mGENn|Sd?RuSd>mo2{SJ7S(kE^s=nXyb$e#(>6;D`-dC8Tl&>(I z_&ht_%R5djV3fs)jwnQw#UR0sT~P@^O^M1BQ^sPk_>WYGO@#zij8sLXS*MyEr#ANX z1UDo@|Jl{*{vDMcZ(n+SFb9z1YSE0XQ$n+2tD7VFztMVl&aP)q3fpVPZruRS#os7B zeCXx)m5RX^&cJ?gshc2Pw?Ss)pIbaNwrb?)P>s{HS72HB1(r?_Vc9^5mp7|y{Z)e* zx)G6~j3itCSVV=Is;f|ynT(01MB<9x*4o$J*4O%L*;-K7bfijh_67Rg-0%<oE=uj- qBp!oC%+`|V7OV#)fAEJFlP4FM6{jZ0R^8$32HcRyHsJbW1K$D9Ba?yv delta 21 ccmeD9D!pTqL_-T>3sVbo3rh>@7B&k>0AI=nNdN!<