diff --git a/Apps/Api/Api.csproj b/Apps/Api/Api.csproj index 413ada0..ccc7f75 100644 --- a/Apps/Api/Api.csproj +++ b/Apps/Api/Api.csproj @@ -5,6 +5,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Apps/Api/Controllers/AccountsController.cs b/Apps/Api/Controllers/AccountsController.cs new file mode 100644 index 0000000..bc13bd0 --- /dev/null +++ b/Apps/Api/Controllers/AccountsController.cs @@ -0,0 +1,94 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Threading.Tasks; +using Infrastructure.Identity; +using Infrastructure.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; + +namespace Api.Controllers +{ + [Route("api/accounts")] + [ApiController] + [AllowAnonymous] + public class AccountsController : ControllerBase + { + private readonly UserManager _userManager; + private readonly SignInManager _signinManager; + private readonly TokenOptions _options; + private readonly IUserClaimsPrincipalFactory _claimsFactory; + + public AccountsController(UserManager userManager, SignInManager signinManager, TokenOptions options, IUserClaimsPrincipalFactory claimsFactory) + { + _userManager = userManager; + _signinManager = signinManager; + _options = options; + _claimsFactory = claimsFactory; + } + + [HttpGet("me")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult Me() + { + return Ok(new + { + Id = User.FindFirstValue(ClaimTypes.NameIdentifier), + Name = User.Identity.Name, + Authenticated = User.Identity.IsAuthenticated, + }); + } + + [HttpPost] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Register(RegisterViewModel cmd) + { + var result = await _userManager.CreateAsync( + new Infrastructure.Identity.User { UserName = cmd.Username }, cmd.Password); + + if (!result.Succeeded) + { + return BadRequest(); + } + + return NoContent(); + } + + [HttpPost("token")] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] + public async Task Login(LoginViewModel cmd) + { + var user = await _userManager.FindByNameAsync(cmd.Username); + + if (user == null) + { + return BadRequest(); + } + + var result = await _signinManager.CheckPasswordSignInAsync(user, cmd.Password, false); + + if (!result.Succeeded) + { + return BadRequest(); + } + + var principal = await _claimsFactory.CreateAsync(user); + var descriptor = new SecurityTokenDescriptor + { + Subject = (ClaimsIdentity)principal.Identity, + Expires = DateTime.UtcNow.AddDays(7), + Issuer = _options.Issuer, + Audience = _options.Audience, + SigningCredentials = new SigningCredentials(_options.Key, SecurityAlgorithms.HmacSha256Signature) + }; + var token = new JwtSecurityTokenHandler().CreateEncodedJwt(descriptor); + + return Ok(token); + } + } +} \ No newline at end of file diff --git a/Apps/Api/Controllers/CommentsController.cs b/Apps/Api/Controllers/CommentsController.cs index b9c255b..aeccb36 100644 --- a/Apps/Api/Controllers/CommentsController.cs +++ b/Apps/Api/Controllers/CommentsController.cs @@ -1,5 +1,6 @@ using System; using Application; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -24,6 +25,7 @@ namespace Api.Controllers [HttpGet("{id:guid}")] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status200OK)] + [AllowAnonymous] public CommentDTO Show(Guid id) { return _commentService.GetCommentById(id); diff --git a/Apps/Api/Controllers/LinksController.cs b/Apps/Api/Controllers/LinksController.cs index 24017fa..7e00dcc 100644 --- a/Apps/Api/Controllers/LinksController.cs +++ b/Apps/Api/Controllers/LinksController.cs @@ -1,5 +1,6 @@ using System; using Application; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -23,6 +24,7 @@ namespace Api.Controllers /// /// [HttpGet] + [AllowAnonymous] public LinkDTO[] GetLatest() { return _linkService.GetAllLinks(); @@ -36,6 +38,7 @@ namespace Api.Controllers [HttpGet("{id:guid}")] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status200OK)] + [AllowAnonymous] public LinkDTO GetById(Guid id) { return _linkService.GetLinkById(id); @@ -47,6 +50,7 @@ namespace Api.Controllers /// /// [HttpGet("{id:guid}/comments")] + [AllowAnonymous] public CommentDTO[] Comments(Guid id) { return _commentService.GetAllLinkComments(id); diff --git a/Apps/Api/Startup.cs b/Apps/Api/Startup.cs index cbd403a..c9a7bbd 100644 --- a/Apps/Api/Startup.cs +++ b/Apps/Api/Startup.cs @@ -4,28 +4,81 @@ using System.Linq; using System.Threading.Tasks; using Infrastructure; using Infrastructure.Filters; +using Infrastructure.Identity; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.Tokens; namespace Api { public class Startup { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + // This method gets called by the runtime. Use this method to add services to the container. // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 public void ConfigureServices(IServiceCollection services) { + var tokenOptions = Configuration.GetSection("Token").Get(); + services.AddSingleton(tokenOptions); + // services.AddHNServicesInMemory(); services.AddHNServicesEF(); + + services + .AddIdentityCore(options => + { + // FIXME uniquement pour nos besoins :) + options.Password.RequiredLength = options.Password.RequiredUniqueChars = 0; + options.Password.RequireDigit = options.Password.RequireLowercase = options.Password.RequireUppercase = options.Password.RequireNonAlphanumeric = false; + }) + .AddRoles() + .AddSignInManager() + .AddEntityFrameworkStores(); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = tokenOptions.Issuer, + ValidAudience = tokenOptions.Audience, + IssuerSigningKey = tokenOptions.Key + }; + }); + services.AddControllers(options => { options.Filters.Add(); + options.Filters.Add(new AuthorizeFilter()); }); services.AddOpenApiDocument(doc => { + doc.AddSecurity("JWT", Enumerable.Empty(), new NSwag.OpenApiSecurityScheme + { + Type = NSwag.OpenApiSecuritySchemeType.ApiKey, + Name = "Authorization", + In = NSwag.OpenApiSecurityApiKeyLocation.Header, + Description = "Jeton: Bearer {votre jeton}" + }); + doc.PostProcess = od => { od.Info.Title = "Hacker News Clone API"; @@ -57,6 +110,9 @@ namespace Api app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => { endpoints.MapGet("/", async context => diff --git a/Apps/Api/TokenOptions.cs b/Apps/Api/TokenOptions.cs new file mode 100644 index 0000000..af30c14 --- /dev/null +++ b/Apps/Api/TokenOptions.cs @@ -0,0 +1,14 @@ +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Api +{ + public class TokenOptions + { + public string Issuer { get; set; } + public string Audience { get; set; } + public string SecurityKey { get; set; } + + public SecurityKey Key => new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecurityKey)); + } +} \ No newline at end of file diff --git a/Apps/Api/api.http b/Apps/Api/api.http index b165482..4bdb17d 100644 --- a/Apps/Api/api.http +++ b/Apps/Api/api.http @@ -1,4 +1,5 @@ @url = http://localhost:5000 +@token = Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1laWQiOiJkNzlhYzRmZS1hZTEwLTQ5NTEtODJkOS01YjRhNzk3MjNjZTgiLCJ1bmlxdWVfbmFtZSI6InRlc3QiLCJBc3BOZXQuSWRlbnRpdHkuU2VjdXJpdHlTdGFtcCI6IjNSSlRNTFhLTTU3SjJFRUxITURINUlVV1VSNVFJSE9IIiwibmJmIjoxNjE5Njk3NDA1LCJleHAiOjE2MjAzMDIyMDUsImlhdCI6MTYxOTY5NzQwNSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3QifQ.f-kr-35Q7h4D4KsRWj2SC2zAUJ-CD9XVAfl8ORFPXqg GET {{url}}/api/links @@ -6,9 +7,10 @@ GET {{url}}/api/links POST {{url}}/api/links Content-Type: application/json +Authorization: {{token}} { - "url": "http://google.com" + "url": "http://google.com/other" } ### @@ -27,4 +29,30 @@ Content-Type: application/json ### -GET {{url}}/api/Comments/a8b67787-9e92-4011-b0ed-3688d06043da \ No newline at end of file +GET {{url}}/api/Comments/a8b67787-9e92-4011-b0ed-3688d06043da + +### + +POST {{url}}/api/accounts +Content-Type: application/json + +{ + "Username": "toto", + "Password": "test", + "ConfirmPassword": "test" +} + +### + +POST {{url}}/api/accounts/token +Content-Type: application/json + +{ + "Username": "test", + "Password": "test" +} + +### + +GET {{url}}/api/accounts/me +Authorization: {{token}} \ No newline at end of file diff --git a/Apps/Api/appsettings.json b/Apps/Api/appsettings.json index 55f4a8f..f915d8b 100644 --- a/Apps/Api/appsettings.json +++ b/Apps/Api/appsettings.json @@ -9,5 +9,10 @@ "ConnectionStrings": { "Default": "Data Source=../Website/hn.db" }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Token": { + "Issuer": "http://localhost", + "Audience": "http://localhost", + "SecurityKey": "tS4wW5bA1cI5iY6j" + } } diff --git a/Apps/Api/swagger.json b/Apps/Api/swagger.json index 07c5ada..da3c7a6 100644 --- a/Apps/Api/swagger.json +++ b/Apps/Api/swagger.json @@ -6,6 +6,96 @@ "version": "1.0.0" }, "paths": { + "/api/accounts/me": { + "get": { + "tags": [ + "Accounts" + ], + "operationId": "Accounts_Me", + "responses": { + "200": { + "description": "" + } + } + } + }, + "/api/accounts": { + "post": { + "tags": [ + "Accounts" + ], + "operationId": "Accounts_Register", + "requestBody": { + "x-name": "cmd", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterViewModel" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "204": { + "description": "" + } + } + } + }, + "/api/accounts/token": { + "post": { + "tags": [ + "Accounts" + ], + "operationId": "Accounts_Login", + "requestBody": { + "x-name": "cmd", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginViewModel" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/api/Comments/{id}": { "get": { "tags": [ @@ -269,6 +359,47 @@ } } }, + "RegisterViewModel": { + "type": "object", + "additionalProperties": false, + "required": [ + "username", + "password", + "confirmPassword" + ], + "properties": { + "username": { + "type": "string", + "minLength": 1 + }, + "password": { + "type": "string", + "minLength": 1 + }, + "confirmPassword": { + "type": "string", + "minLength": 1 + } + } + }, + "LoginViewModel": { + "type": "object", + "additionalProperties": false, + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string", + "minLength": 1 + }, + "password": { + "type": "string", + "minLength": 1 + } + } + }, "CommentDTO": { "type": "object", "additionalProperties": false, @@ -288,6 +419,10 @@ "downvotesCount": { "type": "integer", "format": "int32" + }, + "createdByUsername": { + "type": "string", + "nullable": true } } }, @@ -337,6 +472,10 @@ "commentsCount": { "type": "integer", "format": "int32" + }, + "createdByUsername": { + "type": "string", + "nullable": true } } }, @@ -354,6 +493,19 @@ } } } + }, + "securitySchemes": { + "JWT": { + "type": "apiKey", + "description": "Jeton: Bearer {votre jeton}", + "name": "Authorization", + "in": "header" + } } - } + }, + "security": [ + { + "JWT": [] + } + ] } \ No newline at end of file diff --git a/Apps/Website/hn.db-wal b/Apps/Website/hn.db-wal index 8878a3a..b112f61 100644 Binary files a/Apps/Website/hn.db-wal and b/Apps/Website/hn.db-wal differ