Feature/Auth: last one was authentication, this one is authorization #4

Merged
homeburger merged 33 commits from feature/auth into main 2026-04-23 00:18:39 -05:00
8 changed files with 62 additions and 44 deletions
Showing only changes of commit 317a7bce9d - Show all commits

View File

@@ -1,4 +1,5 @@
// system usings
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -7,23 +8,27 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using System.Text;
// homeburger usings
using agologumApi.Models;
using agologumApi.Services;
var builder = WebApplication.CreateBuilder(args);
// make sure the jwt key exists or else abort, security issue
var key = builder.Configuration["Jwt:Key"];
if(key == null) return;
// connect to the sql database
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddControllers();
// services
// add our services
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<ItemService>();
builder.Services.AddScoped<JwtService>();
// if this grows sufficiently large we can put elsewhere
// configuration for jwt authentication
builder.Services.AddIdentity<User, IdentityRole>()
@@ -46,6 +51,8 @@ builder.Services.AddAuthentication(options => {
};
});
// authorization configurations; here's where we register our permissions to policies
// TODO: this suspiciously looks able to be automated through a for loop, only if we can have a static dictionary maybe though?
builder.Services.AddAuthorization(options => {
options.AddPolicy(Permission.SensitiveData_Read, policy =>
@@ -70,6 +77,7 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
// cors; scary needs to be fixed
builder.Services.AddCors(options =>
{
options.AddPolicy("dev",
@@ -78,17 +86,14 @@ builder.Services.AddCors(options =>
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
}); // TODO: scary please fix this
});
// more middleware; probably uncessary at this stage
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// https://www.reddit.com/r/dotnet/comments/1h7vzbs/how_do_you_guys_handle_authorization_on_a_web_api/
// add authorization here
// controllers will have endpoints based on authorization
// frontend is a different story
// build app
var app = builder.Build();
app.UseForwardedHeaders();
@@ -121,17 +126,6 @@ using (var scope = app.Services.CreateScope()) {
}
}
// TODO: abstract this away
// auto seed Identity roles
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
string[] roles = { "guest", "user", "dev", "mod", "admin", "superuser", "role1", "role2" };
foreach(string role in roles) {
if(!await roleManager.RoleExistsAsync(role)) {
await roleManager.CreateAsync(new IdentityRole(role));
}
}
}
app.Run();

View File

@@ -13,10 +13,11 @@ public class AuthController : ControllerBase {
// identity things
private readonly UserManager<User> userManager_;
private readonly SignInManager<User> signInManager_;
// services
private readonly JwtService jwt_;
private readonly UserService userService_;
// class constructor (where are my initializer lists man)
public AuthController(UserManager<User> userManager, SignInManager<User> signInManager, JwtService jwt, UserService userService) {
userManager_ = userManager;
@@ -25,43 +26,49 @@ public class AuthController : ControllerBase {
userService_ = userService;
}
// register endpoint
[HttpPost("register")]
public async Task<ActionResult> Register(RegisterDto dto) {
// create a new user out of the dto from the request
User user = new User {
UserName = dto.UserName,
Email = dto.Email,
CreatedAt = DateTime.UtcNow // yeah why not utc
};
// assigning roles to user
// assigning roles to user. create a user starting with x to give it permissions to read sensitive data
if(dto.UserName.StartsWith("x")) {
user.Permissions = new List<string> { Permission.SensitiveData_Read };
} else if(dto.UserName == "bard") {
user.Permissions = new List<string> { Permission.SensitiveData_Read, Permission.SensitiveData_Modify };
}
}
// use Identity's user manager to add to db; error check if failed
var result = await userManager_.CreateAsync(user, dto.Password);
if(!result.Succeeded) return BadRequest(result.Errors);
// respond to post as necessary
return CreatedAtAction(
nameof(Register),
new { id = user.Id }
);
}
// login endpoint
[HttpPost("login")]
public async Task<ActionResult> Login(LoginDto dto)
{
// get the user from the database given the username
var user = await userManager_.FindByNameAsync(dto.UserName);
// user not found with that name
if (user == null) return Unauthorized(); // unauthorized instead of not found to not give away info
if (user == null) return Unauthorized();
// use identity's password validation
var result = await signInManager_.CheckPasswordSignInAsync(user, dto.Password, false);
// if failed then youre not real !
if(!result.Succeeded) return Unauthorized();
// login sucess, give you an authentication token
var accessToken = await jwt_.GenerateJwt(user);
var refreshToken = jwt_.GenerateRefreshToken();
var refreshToken = jwt_.GenerateRefreshToken(); // the refresh token is good enough to refresh your access token
RefreshToken newTokenObject = new RefreshToken {
Token = refreshToken,
UserId = user.Id,
@@ -70,43 +77,44 @@ public class AuthController : ControllerBase {
IsRevoked = false
};
await jwt_.AddRefreshToken(newTokenObject);
// the jwt says we trust who you are and can substitute it for login
// contains permissions claims too
if(user.UserName == "bard") { // tempory because im silly
await userManager_.AddToRoleAsync(user, "superuser");
} // eventually ill have an endpoint for adding/removing roles
if(dto.UserName == "bard") {
user.Permissions = new List<string> { Permission.SensitiveData_Read, Permission.SensitiveData_Modify };
await userService_.Update(user.Id, user);
}
// return both access and refresh token
return Ok(new { accessToken, refreshToken });
}
// logout endpoint
[Authorize] // authorize is handled by middleware
[HttpPost("logout")]
public async Task<ActionResult> Logout(string refreshTokenString) {
// revoke refresh token
bool success = await jwt_.RevokeRefreshToken(refreshTokenString);
if(!success) return NotFound();
// frontend refreshes page and detects logout
return Ok();
}
// refresh token endpoint
[HttpPost("refresh")] // allow-anonymous by default
public async Task<ActionResult> Refresh(TokenDto request) {
// reached when the frontend gets an unauthorized response and autoattempts to refresh if available
// get token from request and check if its valid
RefreshToken? storedToken = await jwt_.GetRefreshToken(request.RefreshToken);
if (storedToken == null) return Unauthorized();
bool valid = (storedToken.IsRevoked) ||
(storedToken.ExpiresAt < DateTime.UtcNow);
if(!valid) return Unauthorized(); // TODO: delete the invalid token
// get user from the token and give them new tokens
User? user = await jwt_.GetUser(storedToken.UserId);
if(user == null) return NotFound();
string? newAccessToken = await jwt_.GenerateJwt(user);
if(newAccessToken == null) return NotFound();
string newRefreshToken = jwt_.GenerateRefreshToken();
// construct new token
storedToken.IsRevoked = true;
RefreshToken newTokenObject = new RefreshToken {
Token = newRefreshToken,
@@ -117,10 +125,9 @@ public class AuthController : ControllerBase {
};
await jwt_.AddRefreshToken(newTokenObject);
// return new tokens
return Ok(new { accessToken = newAccessToken, refreshToken = newRefreshToken });
}
// TODO

View File

@@ -68,6 +68,7 @@ public class UsersController : ControllerBase {
[HttpDelete("{id}/{permission}")]
public async Task<ActionResult> removePermission(string id, string permission) {
// get the user this request comes from. since it passed identity auth we can trust it
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if(permission == Permission.SensitiveData_Modify && userId == id) return BadRequest(); // dont allow permission removal of whats allowing us to re-add premissions

View File

@@ -10,7 +10,7 @@ public class User : IdentityUser {
// TODO: make this a list of UserPermissions
// where a userpermission has an Id, Permission (string), and userId string
// then we can do something like: get all users with this permission
public List<string>? Permissions { get; set; } = new();
public List<string>? Permissions { get; set; } = new(); // because this isnt very relational database happy
// properties inherited from IdentityUser:
/*
@@ -54,12 +54,14 @@ public class LoginDto {
public class UserDto {
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // gets compressed to a string'
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // datetimes get compressed to a string
public List<string>? Permissions { get; set; } = [];
public string? Email { get; set; } = "";
public string Id { get; set; } = "";
public string? UserName { get; set; } = "";
// constructor out of a full User object
// REMEMBER: when adding fields to UserDto they must also be set in this constructor or else stuff breaks
public UserDto(User user) {
CreatedAt = user.CreatedAt;
Email = user.Email;

View File

@@ -5,6 +5,7 @@ using agologumApi.Models;
namespace agologumApi.Services;
// basic CRUD operations for items in the database
public class ItemService {
private readonly AppDbContext db_;

View File

@@ -21,14 +21,16 @@ public class JwtService {
userManager_ = userManager;
}
// create a jwt string given a user (user contains permissions which go into claims)
public async Task<string?> GenerateJwt(User user) {
// security stuff
string? jwtKey = config_["Jwt:Key"];
if(jwtKey == null) return null;
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
// make sure the user is real
if(user.UserName == null) return null;
// not too sure
@@ -37,13 +39,15 @@ public class JwtService {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())
};
// add each permission that the user has into the claims
List<string>? permissions = user.Permissions;
if(permissions != null) {
foreach(var perm in permissions) {
foreach(string perm in permissions) {
claims.Add(new Claim("permission", perm));
}
}
// construct that token
var token = new JwtSecurityToken(
issuer: "agologum",
audience: "agologum",
@@ -56,6 +60,7 @@ public class JwtService {
}
// generating a refresh token is just like a long random password
public string GenerateRefreshToken() {
byte[] randomBytes = new byte[64];
@@ -64,10 +69,12 @@ public class JwtService {
}
// we store refresh tokens on our side to check against when a user requests a refresh
public async Task<RefreshToken?> GetRefreshToken(string refreshTokenString) {
return await db_.RefreshTokens.FirstOrDefaultAsync(u => u.Token == refreshTokenString);
}
// add a refresh token to the token db store
public async Task<RefreshToken> AddRefreshToken(RefreshToken refreshToken) {
db_.RefreshTokens.Add(refreshToken);
await db_.SaveChangesAsync();
@@ -77,8 +84,9 @@ public class JwtService {
// helper to get the User from the id that exists in a refresh token object
public async Task<User?> GetUser(string id) {
return await db_.Users.FindAsync(id);
}
} // since other places aren't good for having references to db contexts
// remove refresh token from our store; called when user logs out
public async Task<bool> RevokeRefreshToken(string refreshTokenString) {
var refreshToken = await db_.RefreshTokens.FirstOrDefaultAsync(u => u.Token == refreshTokenString);
if(refreshToken == null) return false;

View File

@@ -13,18 +13,22 @@ public class UserService {
db_ = db;
}
// get all users
public async Task<List<User>> GetAll() {
return await db_.Users.ToListAsync();
}
// get one user with id of id
public async Task<User?> GetById(string id) {
return await db_.Users.FindAsync(id);
}
// get one user with username of name
public async Task<User?> GetByName(string name) {
return await db_.Users.FirstOrDefaultAsync(u => u.UserName == name);
}
// delete one user with id of id
public async Task<bool> Delete(string id) {
User? User = await db_.Users.FindAsync(id);
if(User != null) {
@@ -36,6 +40,7 @@ public class UserService {
}
}
// update user of id with user
public async Task<User?> Update(string id, User user) {
User? oldUser = await db_.Users.FindAsync(id);

View File

@@ -88,6 +88,6 @@ api.interceptors.response.use(response => response, async error => { // mainly f
})
// TODO: if you get a 403 while navigating then redirect to the last authenticated page
// if you gert a 403 on a form submissio nthen do like an unauthorized popup
// if you gert a 403 on a form submissio nthen do like an unauthorized popup (message: stale session <login link>) (or redirect to login like i said elsewhere)
export default api;