Update documentation and gate tag creation behind admin role, create default admin on database seeding

This commit is contained in:
2025-10-06 16:35:55 +03:00
parent 25dfb97a5b
commit 8d012f04f1
17 changed files with 149 additions and 33 deletions

View File

@@ -0,0 +1,23 @@
meta {
name: Login as admin
type: http
seq: 3
}
post {
url: {{baseUrl}}/api/auth/login
body: json
auth: none
}
body:json {
{
"email": "admin@localhost",
"password": "ChangeMe123#"
}
}
vars:post-response {
accessToken: res.body.accessToken
refreshToken: res.body.refreshToken
}

View File

@@ -7,7 +7,7 @@ meta {
post { post {
url: {{baseUrl}}/api/auth/login url: {{baseUrl}}/api/auth/login
body: json body: json
auth: inherit auth: none
} }
body:json { body:json {

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Refresh token name: Refresh token
type: http type: http
seq: 3 seq: 4
} }
post { post {

View File

@@ -7,7 +7,7 @@ meta {
post { post {
url: {{baseUrl}}/api/auth/register url: {{baseUrl}}/api/auth/register
body: json body: json
auth: inherit auth: none
} }
body:json { body:json {

View File

@@ -7,11 +7,7 @@ meta {
post { post {
url: {{baseUrl}}/api/posts url: {{baseUrl}}/api/posts
body: json body: json
auth: bearer auth: inherit
}
auth:bearer {
token: {{accessToken}}
} }
body:json { body:json {

View File

@@ -7,9 +7,5 @@ meta {
delete { delete {
url: {{baseUrl}}/api/posts/1 url: {{baseUrl}}/api/posts/1
body: none body: none
auth: bearer auth: inherit
}
auth:bearer {
token: {{accessToken}}
} }

View File

@@ -7,11 +7,7 @@ meta {
patch { patch {
url: {{baseUrl}}/api/posts/1 url: {{baseUrl}}/api/posts/1
body: json body: json
auth: bearer auth: inherit
}
auth:bearer {
token: {{accessToken}}
} }
body:json { body:json {

View File

@@ -6,7 +6,7 @@ meta {
patch { patch {
url: {{baseUrl}}/api/tags/:name url: {{baseUrl}}/api/tags/:name
body: none body: json
auth: inherit auth: inherit
} }
@@ -17,3 +17,9 @@ params:query {
params:path { params:path {
name: high-res name: high-res
} }
body:json {
{
"type": "Copyright"
}
}

View File

@@ -3,7 +3,11 @@ meta {
} }
auth { auth {
mode: none mode: bearer
}
auth:bearer {
token: {{accessToken}}
} }
vars:pre-request { vars:pre-request {

View File

@@ -12,8 +12,16 @@ namespace T120B165_ImgBoard.Controllers;
public class AuthController(UserManager<User> userManager, ITokenService tokenService): ControllerBase public class AuthController(UserManager<User> userManager, ITokenService tokenService): ControllerBase
{ {
/// <summary>
/// Creates a new user account.
/// </summary>
/// <param name="dto">Registration data</param>
/// <response code="200">Returns user data</response>
/// <response code="400">If user supplied credentials fail validation</response>
[HttpPost("register")] [HttpPost("register")]
public async Task<ActionResult<User>> Register(RegisterDto dto) [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<SlimUserDto>> Register(RegisterDto dto)
{ {
var user = new User var user = new User
{ {
@@ -27,9 +35,15 @@ public class AuthController(UserManager<User> userManager, ITokenService tokenSe
{ {
return BadRequest(result.Errors); return BadRequest(result.Errors);
} }
return Ok(user); return Ok(SlimUserDto.FromUser(user));
} }
/// <summary>
/// Authenticates and creates a pair of access and refresh tokens.
/// </summary>
/// <param name="dto">Data with refresh token</param>
/// <response code="200">Returns refresh and access tokens</response>
/// <response code="401">If the credentials are incorrect</response>
[HttpPost("login")] [HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
@@ -44,6 +58,12 @@ public class AuthController(UserManager<User> userManager, ITokenService tokenSe
return Ok(new TokenDto(AccessToken: accessToken, RefreshToken: refreshToken)); return Ok(new TokenDto(AccessToken: accessToken, RefreshToken: refreshToken));
} }
/// <summary>
/// Consume refresh token to create new access and refresh tokens.
/// </summary>
/// <param name="dto">Data with refresh token</param>
/// <response code="200">Returns new refresh and access tokens</response>
/// <response code="401">If refresh token is missing or is expired</response>
[HttpPost("refresh")] [HttpPost("refresh")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using T120B165_ImgBoard.Dtos; using T120B165_ImgBoard.Dtos;
using T120B165_ImgBoard.Dtos.Tag; using T120B165_ImgBoard.Dtos.Tag;
@@ -11,9 +12,21 @@ namespace T120B165_ImgBoard.Controllers;
[Route("api/tags")] [Route("api/tags")]
public class TagController(ITagService tagService) : ControllerBase public class TagController(ITagService tagService) : ControllerBase
{ {
/// <summary>
/// Creates a new tag.
/// </summary>
/// <param name="dto">New tag data.</param>
/// <response code="201">Returns the newly created tag</response>
/// <response code="400">If request is malformed</response>
/// <response code="401">If authentication is missing</response>
/// <response code="403">If authorization is missing</response>
/// <response code="409">If tag already exists with such name</response>
[HttpPost] [HttpPost]
[Authorize(Roles = UserRoles.Admin)]
[ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<Tag>> Create(CreateTagDto dto) public async Task<ActionResult<Tag>> Create(CreateTagDto dto)
{ {
@@ -31,6 +44,12 @@ public class TagController(ITagService tagService) : ControllerBase
return CreatedAtAction(nameof(Get), new { name = createdTag.Name }, createdTag); return CreatedAtAction(nameof(Get), new { name = createdTag.Name }, createdTag);
} }
/// <summary>
/// Get a paginated list of tags.
/// </summary>
/// <param name="pageNumber">The page number</param>
/// <response code="200">Returns paginated list</response>
/// <response code="400">If request is malformed</response>
[HttpGet] [HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -39,6 +58,13 @@ public class TagController(ITagService tagService) : ControllerBase
return Ok(await tagService.GetAll(pageNumber)); return Ok(await tagService.GetAll(pageNumber));
} }
/// <summary>
/// Get specific tag by name.
/// </summary>
/// <param name="name">The tag name</param>
/// <response code="200">The tag data</response>
/// <response code="400">If request is malformed</response>
/// <response code="404">If tag does not exist</response>
[HttpGet("{name}")] [HttpGet("{name}")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -53,9 +79,21 @@ public class TagController(ITagService tagService) : ControllerBase
return Ok(tag); return Ok(tag);
} }
/// <summary>
/// Delete specified tag.
/// </summary>
/// <param name="name">The tag name</param>
/// <response code="204">Indicates tag deletion success</response>
/// <response code="400">If request is malformed</response>
/// <response code="401">If authentication is missing</response>
/// <response code="403">If authorization is missing</response>
/// <response code="404">If tag does not exist</response>
[HttpDelete("{name}")] [HttpDelete("{name}")]
[Authorize(Roles = UserRoles.Admin)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Delete(string name) public async Task<ActionResult> Delete(string name)
{ {
@@ -64,9 +102,22 @@ public class TagController(ITagService tagService) : ControllerBase
return NoContent(); return NoContent();
} }
/// <summary>
/// Update specified tag.
/// </summary>
/// <param name="name">The tag name</param>
/// <param name="dto">The new tag data</param>
/// <response code="200">Indicates tag update success</response>
/// <response code="400">If request is malformed</response>
/// <response code="401">If authentication is missing</response>
/// <response code="403">If authorization is missing</response>
/// <response code="404">If tag does not exist</response>
[HttpPatch("{name}")] [HttpPatch("{name}")]
[Authorize(Roles = UserRoles.Admin)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Tag>> Update(string name, EditTagDto dto) public async Task<ActionResult<Tag>> Update(string name, EditTagDto dto)
{ {

View File

@@ -5,8 +5,9 @@ namespace T120B165_ImgBoard.Data;
public static class DbInitializer public static class DbInitializer
{ {
public static async Task SeedRolesAsync(IServiceProvider serviceProvider) public static async Task SeedAuth(IServiceProvider serviceProvider)
{ {
Console.WriteLine("Seeding Auth...");
var roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>(); var roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();
string[] roleNames = [UserRoles.Admin, UserRoles.Regular]; string[] roleNames = [UserRoles.Admin, UserRoles.Regular];
foreach (var roleName in roleNames) foreach (var roleName in roleNames)
@@ -16,5 +17,16 @@ public static class DbInitializer
await roleManager.CreateAsync(new IdentityRole(roleName)); await roleManager.CreateAsync(new IdentityRole(roleName));
} }
} }
var userManager = serviceProvider.GetRequiredService<UserManager<User>>();
var adminUser = new User
{
UserName = "admin",
Email = "admin@localhost",
};
await userManager.CreateAsync(adminUser, "ChangeMe123#");
await userManager.AddToRoleAsync(adminUser, UserRoles.Regular);
await userManager.AddToRoleAsync(adminUser, UserRoles.Admin);
Console.WriteLine("Auth seeding complete.");
} }
} }

View File

@@ -17,7 +17,4 @@ public class RefreshToken
public DateTime Expires { get; set; } public DateTime Expires { get; set; }
} }
public class User : IdentityUser public class User : IdentityUser;
{
//public List<Post> Posts { get; set; }
};

View File

@@ -1,9 +1,12 @@
using System.Net;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
using NSwag;
using NSwag.Generation.Processors.Security;
using T120B165_ImgBoard.Data; using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models; using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Services; using T120B165_ImgBoard.Services;
@@ -21,8 +24,17 @@ public class Program
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddControllers().AddNewtonsoftJson( builder.Services.AddControllers().AddNewtonsoftJson(
options => options.SerializerSettings.Converters.Add(new StringEnumConverter())); options => options.SerializerSettings.Converters.Add(new StringEnumConverter()));
builder.Services.AddOpenApiDocument(); builder.Services.AddOpenApiDocument(cfg =>
builder.Services.AddOpenApi(); {
cfg.OperationProcessors.Add(new OperationSecurityScopeProcessor("auth"));
cfg.DocumentProcessors.Add(new SecurityDefinitionAppender("auth", new OpenApiSecurityScheme
{
Type = OpenApiSecuritySchemeType.Http,
In = OpenApiSecurityApiKeyLocation.Header,
Scheme = "bearer",
BearerFormat = "jwt"
}));
});
builder.Services.AddIdentity<User, IdentityRole>() builder.Services.AddIdentity<User, IdentityRole>()
@@ -84,7 +96,7 @@ public class Program
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); //app.MapOpenApi();
app.UseOpenApi(); app.UseOpenApi();
app.UseSwaggerUi(); app.UseSwaggerUi();
} }
@@ -95,11 +107,12 @@ public class Program
var context = services.GetRequiredService<ImgBoardContext>(); var context = services.GetRequiredService<ImgBoardContext>();
context.Database.EnsureCreated(); context.Database.EnsureCreated();
DbInitializer.SeedRolesAsync(services).GetAwaiter().GetResult(); DbInitializer.SeedAuth(services).GetAwaiter().GetResult();
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();

View File

@@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data; using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Dtos;
using T120B165_ImgBoard.Models; using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Utils; using T120B165_ImgBoard.Utils;

View File

@@ -6,6 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>T120B165_ImgBoard</RootNamespace> <RootNamespace>T120B165_ImgBoard</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -22,8 +22,10 @@
MARIADB_USER: imgboard MARIADB_USER: imgboard
MARIADB_PASSWORD: supersecretpassword MARIADB_PASSWORD: supersecretpassword
volumes: volumes:
- ./db_data:/var/lib/mysql - db_data:/var/lib/mysql
volumes: volumes:
app_data: app_data:
driver: local driver: local
db_data:
driver: local