Archived
Private
Public Access
1
0
This commit is contained in:
2022-12-18 13:30:02 +01:00
commit 0e94ffa3c6
85 changed files with 26673 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

3
ProjectManager.Backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
obj
bin
appsettings.Development.json

View File

@@ -0,0 +1,63 @@
using Docker.DotNet;
using Docker.DotNet.Models;
using Microsoft.Extensions.Options;
using ProjectManager.Backend.Options;
namespace ProjectManager.Backend.Apis;
public interface IDockerApi {
public Task<string> CreateContainer(string image, int port, string hostVolumePath, string name);
public Task StartContainer(string containerId);
public Task StopContainer(string containerId);
public Task DeleteContainer(string containerId);
public Task<bool> IsContainerStarted(string containerId);
}
public sealed class DockerApi : IDockerApi {
private readonly DockerClient _client;
public DockerApi(IOptions<GeneralOptions> options) {
_client = new DockerClientConfiguration(new Uri(options.Value.DockerPath)).CreateClient();
}
public async Task<string> CreateContainer(string image, int port, string hostVolumePath, string name) {
await _client.Images.CreateImageAsync(new() {
FromImage = image
}, null, new Progress<JSONMessage>());
var container = await _client.Containers.CreateContainerAsync(new CreateContainerParameters {
Image = image,
ExposedPorts = new Dictionary<string, EmptyStruct> {
{ "8090/tcp", default }
},
HostConfig = new HostConfig {
PortBindings = new Dictionary<string, IList<PortBinding>> {
{ "8090/tcp", new List<PortBinding> { new() { HostPort = port.ToString() } } }
},
Binds = new List<string> {
$"{hostVolumePath}:/pb_data"
}
},
Name = name
});
return container.ID;
}
public async Task StartContainer(string containerId) {
await _client.Containers.StartContainerAsync(containerId, new());
}
public async Task StopContainer(string containerId) {
await _client.Containers.StopContainerAsync(containerId, new());
}
public async Task DeleteContainer(string containerId) {
await _client.Containers.RemoveContainerAsync(containerId, new());
}
public async Task<bool> IsContainerStarted(string containerId) {
var containers = await _client.Containers.ListContainersAsync(new());
var container = containers.SingleOrDefault(c => c.ID == containerId);
return container != null;
}
}

View File

@@ -0,0 +1,89 @@
using Microsoft.Extensions.Options;
using ProjectManager.Backend.Entities;
using ProjectManager.Backend.Options;
namespace ProjectManager.Backend.Apis;
public interface IProjectApi {
public Project[] GetProjects(string userId);
public Project GetProject(string projectId);
public Task<string> AddProject(string name, string ownerId);
public bool EditProject(string projectId, string name);
public Task DeleteProject(string projectId);
}
public class ProjectApi : IProjectApi {
private readonly DatabaseContext _context;
private readonly GeneralOptions _options;
private readonly IDockerApi _docker;
private readonly IProxyApi _proxy;
public ProjectApi(DatabaseContext context, IOptions<GeneralOptions> options, IDockerApi docker, IProxyApi proxy) {
_context = context;
_options = options.Value;
_docker = docker;
_proxy = proxy;
}
public Project[] GetProjects(string userId) {
return _context.Projects.Where(project => project.OwnerId == userId).ToArray();
}
public Project GetProject(string projectId) {
return _context.Projects.SingleOrDefault(project => project.ProjectId == projectId);
}
public async Task<string> AddProject(string name, string ownerId) {
if (name.Length > 255) return null;
// Get available port
var split = _options.PortRange.Split('-');
var portRange = Enumerable.Range(int.Parse(split[0]), int.Parse(split[1]));
var usedPorts = _context.Projects.Select(p => p.Port).ToArray();
var port = portRange.FirstOrDefault(port => !usedPorts.Contains(port));
if (port == 0) return null;
var project = new Project {
ProjectId = Guid.NewGuid().ToString(),
OwnerId = ownerId,
Name = name,
Port = port
};
var container = await _docker.CreateContainer($"ghcr.io/muchobien/pocketbase:{_options.PocketBaseVersion}", port, _options.Root + project.ProjectId, $"{project.Name}_{project.ProjectId}");
await _docker.StartContainer(container);
project.ContainerName = container;
var (proxyId, certificateId) = await _proxy.AddLocation(project.ProjectId, project.Port);
project.ProxyId = proxyId;
project.CertificateId = certificateId;
_context.Projects.Add(project);
await _context.SaveChangesAsync();
return project.ProjectId;
}
public bool EditProject(string projectId, string name) {
if (name.Length > 255) return false;
var project = GetProject(projectId);
if (project == null) return false;
project.Name = name;
_context.Projects.Update(project);
_context.SaveChanges();
return true;
}
public async Task DeleteProject(string projectId) {
if (!_context.Projects.Any(project => project.ProjectId == projectId)) return;
var project = _context.Projects.Single(project => project.ProjectId == projectId);
await _docker.StopContainer(project.ContainerName);
await _docker.DeleteContainer(project.ContainerName);
await _proxy.RemoveLocation(project.ProxyId, project.CertificateId);
_context.Projects.Remove(project);
await _context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,84 @@
using System.Dynamic;
using System.Text.Json;
using Microsoft.Extensions.Options;
using ProjectManager.Backend.Options;
namespace ProjectManager.Backend.Apis;
public interface IProxyApi {
public Task<(int, int)> AddLocation(string projectId, int port);
public Task RemoveLocation(int proxyId, int certificateId);
}
public sealed class ProxyApi : IProxyApi {
private readonly HttpClient _client;
private readonly ProxyOptions _options;
public ProxyApi(IOptions<ProxyOptions> options) {
_client = new HttpClient();
_options = options.Value;
}
private async Task Login() {
var result = await _client.PostAsJsonAsync(_options.Url + "/api/tokens",
new ProxyAuth(_options.Email, _options.Password), JsonSerializerOptions.Default);
var response = await result.Content.ReadFromJsonAsync<TokenResponse>();
_client.DefaultRequestHeaders.Clear();
_client.DefaultRequestHeaders.Add("Authorization", $"Bearer {response?.token}");
}
public async Task<(int, int)> AddLocation(string projectId, int port) {
if (!_options.Enable) return (-1, -1);
await Login();
var result = await _client.PostAsJsonAsync(_options.Url + "/api/nginx/proxy-hosts",
new CreateData($"{projectId}.{_options.Domain}", _options.Host, port, _options.Email));
dynamic data = await result.Content.ReadFromJsonAsync<ExpandoObject>();
if (data == null) return (-1, -1);
int id = Convert.ToInt32($"{data.id}");
int certificateId = Convert.ToInt32($"{data.certificate_id}");
return (id, certificateId);
}
public async Task RemoveLocation(int proxyId, int certificateId) {
if (proxyId == -1 || certificateId == -1) return;
await Login();
await _client.DeleteAsync(_options.Url + "/api/nginx/proxy-hosts/" + proxyId);
await _client.DeleteAsync(_options.Url + "/api/nginx/certificates/" + certificateId);
}
private sealed record ProxyAuth(string identity, string secret);
private sealed record TokenResponse(string token, string expires);
private sealed class CreateData {
public string access_list_id { get; set; } = "0";
public string advanced_config { get; set; } = " location / {\r\n proxy_pass http://%docker_ip%;\r\n proxy_hide_header X-Frame-Options;\r\n }";
public bool allow_websocket_upgrade { get; set; } = true;
public bool block_exploits { get; set; } = true;
public bool caching_enabled { get; set; } = true;
public string certificate_id { get; set; } = "new";
public string[] domain_names { get; set; }
public string forward_host { get; set; }
public int forward_port { get; set; }
public string forward_scheme { get; set; } = "http";
public bool hsts_enabled { get; set; } = false;
public bool hsts_subdomains { get; set; } = false;
public bool http2_support { get; set; } = true;
public string[] locations { get; set; } = Array.Empty<string>();
public bool ssl_forced { get; set; } = true;
public SslMeta meta { get; set; }
public CreateData(string domain, string ip, int port, string email) {
domain_names = new[] { domain };
forward_host = ip;
forward_port = port;
meta = new SslMeta {
letsencrypt_email = email
};
advanced_config = advanced_config.Replace("%docker_ip%", $"{ip}:{port}");
}
}
private sealed class SslMeta {
public bool dns_challenge { get; set; } = false;
public bool letsencrypt_agree { get; set; } = true;
public string letsencrypt_email { get; set; }
}
}

View File

@@ -0,0 +1,66 @@
using Microsoft.Extensions.Options;
using ProjectManager.Backend.Entities;
using ProjectManager.Backend.Options;
namespace ProjectManager.Backend.Apis;
public interface ITokenApi {
public string GetValidToken(string userId, string clientIp);
public bool ValidateToken(string tokenId, string clientIp);
public User GetUserFromToken(string tokenId);
}
public sealed class TokenApi : ITokenApi {
private readonly DatabaseContext _context;
private readonly GeneralOptions _options;
private readonly IUserApi _users;
public TokenApi(DatabaseContext context, IOptions<GeneralOptions> options, IUserApi users) {
_context = context;
_options = options.Value;
_users = users;
}
public string GetValidToken(string userId, string clientIp) {
var token = _context.Tokens.SingleOrDefault(token => token.UserId == userId && token.ClientIp == clientIp);
if (token == null) {
token = new() {
TokenId = Guid.NewGuid().ToString(),
UserId = userId,
ClientIp = clientIp,
Created = DateTime.Now
};
_context.Tokens.Add(token);
_context.SaveChanges();
return token.TokenId;
}
if (!ValidateToken(token.TokenId, clientIp)) {
_context.Tokens.Remove(token);
token = new() {
TokenId = Guid.NewGuid().ToString(),
UserId = userId,
ClientIp = clientIp,
Created = DateTime.Now
};
_context.Tokens.Add(token);
_context.SaveChanges();
return token.TokenId;
}
return token.TokenId;
}
public bool ValidateToken(string tokenId, string clientIp) {
if (tokenId is null || clientIp is null) return false;
var token = _context.Tokens.SingleOrDefault(token => token.TokenId == tokenId && token.ClientIp == clientIp);
if (token is null) return false;
if (_context.Users.SingleOrDefault(user => user.UserId == token.UserId) == null) return false;
return DateTime.Now - token.Created < TimeSpan.FromDays(_options.TokenTimeInDays);
}
public User GetUserFromToken(string tokenId) {
return _users.GetUser(_context.Tokens.SingleOrDefault(token => token.TokenId == tokenId)?.UserId);
}
}

View File

@@ -0,0 +1,121 @@
using System.Text;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
using Microsoft.Extensions.Options;
using ProjectManager.Backend.Entities;
using ProjectManager.Backend.Options;
namespace ProjectManager.Backend.Apis;
public interface IUserApi {
public User Login(User login);
public User Register(User register);
public User GetUser(string userId);
public IEnumerable<User> GetUsers();
public bool UpdateUser(User update);
public void DeleteUser(string userId);
public bool CanCreateProject(string userId);
}
public sealed class UserApi : IUserApi {
private readonly DatabaseContext _context;
private readonly IProjectApi _projects;
private readonly GeneralOptions _options;
public UserApi(DatabaseContext context, IProjectApi projects, IOptions<GeneralOptions> options) {
_context = context;
_projects = projects;
_options = options.Value;
}
public User Login(User login) {
if (string.IsNullOrEmpty(login.Email) || string.IsNullOrEmpty(login.Password)) return null;
var hash = Hash128(login.Password, login.Email);
return _context.Users.SingleOrDefault(user => user.Email == login.Email && user.Password == hash);
}
public User Register(User register) {
if (string.IsNullOrEmpty(register.Username) ||
string.IsNullOrEmpty(register.Email) ||
string.IsNullOrEmpty(register.Password)) return null;
if (_context.Users.Any(user => user.Email == register.Email || user.Username == register.Username))
return null;
if (register.Email.Length > 255 || register.Username.Length > 255 || register.Password.Length > 255)
return null;
if (!register.Email.Contains('@') || !register.Email.Contains('.')) return null;
if (!register.Password.Any(char.IsLetter) || !register.Password.Any(char.IsDigit) || register.Password.Length < 8) return null;
User user = new() {
UserId = Guid.NewGuid().ToString(),
Email = register.Email,
Username = register.Username,
Password = Hash128(register.Password, register.Email),
MaxProjects = _options.MaxProjects
};
_context.Users.Add(user);
_context.SaveChanges();
return user;
}
public User GetUser(string userId) {
return _context.Users.SingleOrDefault(user => user.UserId == userId);
}
public IEnumerable<User> GetUsers() {
return _context.Users;
}
public bool UpdateUser(User update) {
if (_context.Users.Any(user => user.UserId != update.UserId && (user.Email == update.Email || user.Username == update.Username)))
return false;
if (update.Email.Length > 255 || update.Username.Length > 255 || update.Password.Length > 255)
return false;
if (!string.IsNullOrEmpty(update.Email) && (!update.Email.Contains('@') || !update.Email.Contains('.'))) return false;
if (!string.IsNullOrEmpty(update.Password) && (!update.Password.Any(char.IsLetter) || !update.Password.Any(char.IsDigit) || update.Password.Length < 8)) return false;
var user = _context.Users.SingleOrDefault(user => user.UserId == update.UserId);
if (user == null) return false;
if (!string.IsNullOrEmpty(update.Email)) user.Email = update.Email;
if (!string.IsNullOrEmpty(update.Username)) user.Username = update.Username;
if (!string.IsNullOrEmpty(update.Password)) user.Password = Hash128(update.Password, user.Email);
_context.Users.Update(user);
_context.SaveChanges();
return true;
}
public void DeleteUser(string userId) {
if (!_context.Users.Any(user => user.UserId == userId)) return;
var user = _context.Users.Single(user => user.UserId == userId);
var projects = _projects.GetProjects(userId);
foreach (var project in projects) {
_projects.DeleteProject(project.ProjectId);
}
_context.Users.Remove(user);
_context.SaveChanges();
}
public bool CanCreateProject(string userId) {
return GetUser(userId).MaxProjects > _projects.GetProjects(userId).Length;
}
private static string Hash128(string plainText, string salt) {
try {
string hashed = Convert.ToBase64String(KeyDerivation.Pbkdf2(
password: plainText,
salt: Encoding.Default.GetBytes(salt),
prf: KeyDerivationPrf.HMACSHA256,
iterationCount: 100000,
numBytesRequested: 256 / 8
));
return hashed;
} catch (Exception) { return ""; }
}
}

View File

@@ -0,0 +1,119 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using ProjectManager.Backend.Apis;
using ProjectManager.Backend.Entities;
using ProjectManager.Backend.Options;
using ProjectManager.Backend.Security;
namespace ProjectManager.Backend.Controllers;
[ApiController]
[Route("projects")]
public class ProjectController : ControllerBase {
private readonly IProjectApi _projects;
private readonly ITokenContext _context;
private readonly IUserApi _users;
private readonly IDockerApi _docker;
private readonly ProxyOptions _options;
public ProjectController(
IProjectApi projects,
ITokenContext context,
IUserApi users,
IDockerApi docker,
IOptions<ProxyOptions> options
) {
_projects = projects;
_context = context;
_users = users;
_docker = docker;
_options = options.Value;
}
[Authorized]
[HttpGet]
public async Task<IActionResult> GetProjects() {
var projects = _projects.GetProjects(_context.UserId);
var running = new bool[projects.Length];
for (int i = 0; i < projects.Length; i++)
running[i] = await _docker.IsContainerStarted(projects[i].ContainerName);
return Ok(new { projects, running });
}
[Authorized]
[HttpGet("{projectId}")]
public IActionResult GetProject(string projectId) {
var project = _projects.GetProject(projectId);
if (project == null) return NotFound();
if (project.OwnerId != _context.UserId) return Unauthorized();
return Ok(project);
}
[Authorized]
[HttpPost]
public async Task<IActionResult> AddProject([FromBody] ProjectEdit edit) {
if (!_users.CanCreateProject(_context.UserId)) return Forbid();
var projectId = await _projects.AddProject(edit.Name, _context.UserId);
if (projectId == null) return BadRequest();
return Ok(new { ProjectId = projectId });
}
[Authorized]
[HttpDelete("{projectId}")]
public async Task<IActionResult> DeleteProject(string projectId) {
var project = _projects.GetProject(projectId);
if (project == null) return NotFound();
if (project.OwnerId != _context.UserId) return Unauthorized();
await _projects.DeleteProject(projectId);
return Ok();
}
[Authorized]
[HttpPut("{projectId}")]
public IActionResult EditProject(string projectId, [FromBody] ProjectEdit edit) {
var project = _projects.GetProject(projectId);
if (project == null) return NotFound();
if (project.OwnerId != _context.UserId) return Unauthorized();
_projects.EditProject(projectId, edit.Name);
return Ok();
}
[Authorized]
[HttpGet("{projectId}/url")]
public IActionResult GetProjectUrl(string projectId) {
var project = _projects.GetProject(projectId);
if (project == null) return NotFound();
if (project.OwnerId != _context.UserId) return Unauthorized();
if (_options.Enable) return Redirect($"https://{projectId}.{_options.Domain}/_/");
return Redirect($"http://{_options.Host}:{project.Port}/_/");
}
[Authorized]
[HttpGet("{projectId}/start")]
public async Task<IActionResult> StartProject(string projectId) {
var project = _projects.GetProject(projectId);
if (project == null) return NotFound();
if (project.OwnerId != _context.UserId) return Unauthorized();
await _docker.StartContainer(project.ContainerName);
return Ok();
}
[Authorized]
[HttpGet("{projectId}/stop")]
public async Task<IActionResult> StopProject(string projectId) {
var project = _projects.GetProject(projectId);
if (project == null) return NotFound();
if (project.OwnerId != _context.UserId) return Unauthorized();
await _docker.StopContainer(project.ContainerName);
return Ok();
}
[Authorized]
[HttpGet("{projectId}/status")]
public async Task<IActionResult> ProjectStatus(string projectId) {
var project = _projects.GetProject(projectId);
if (project == null) return NotFound();
if (project.OwnerId != _context.UserId) return Unauthorized();
return Ok(new { Running = await _docker.IsContainerStarted(project.ContainerName) });
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Mvc;
using ProjectManager.Backend.Entities;
using ProjectManager.Backend.Security;
using ProjectManager.Backend.Apis;
namespace ProjectManager.Backend.Controllers;
[ApiController]
[Route("users")]
public sealed class UserController : ControllerBase {
private readonly IUserApi _users;
private readonly ITokenApi _tokens;
private readonly ITokenContext _context;
public UserController(IUserApi users, ITokenApi tokens, ITokenContext context) {
_users = users;
_tokens = tokens;
_context = context;
}
[HttpPost("login")]
public IActionResult Login([FromBody] User login) {
var user = _users.Login(login);
if (user == null) return Conflict();
return Ok(new {Token = _tokens.GetValidToken(user.UserId, HttpContext.Connection.RemoteIpAddress?.ToString())});
}
[HttpPost("register")]
public IActionResult Register([FromBody] User register) {
var user = _users.Register(register);
if (user is null) return Conflict();
return Ok(new {Token = _tokens.GetValidToken(user.UserId, HttpContext.Connection.RemoteIpAddress?.ToString())});
}
[Authorized]
[HttpGet("token")]
public IActionResult CheckToken() {
return Ok(new {Valid = true});
}
[Authorized]
[HttpGet("me")]
public IActionResult GetMe() {
return GetUser(_context.UserId);
}
[Authorized]
[HttpGet]
public IActionResult GetUsers() {
return Ok(_users.GetUsers().Select(user => new User {
UserId = user.UserId,
Email = user.Email,
Username = user.Username
}));
}
[Authorized]
[HttpGet("{userId}")]
public IActionResult GetUser(string userId) {
var user = _users.GetUser(userId);
if (user is null) return NotFound();
user = new() {
UserId = user.UserId,
Email = user.Email,
Username = user.Username
};
return Ok(user);
}
[Authorized]
[HttpPut]
public IActionResult UpdateUser([FromBody] User user) {
if (_context.UserId != user.UserId) return Forbid();
if (!_users.UpdateUser(user)) return BadRequest();
return Ok();
}
[Authorized]
[HttpDelete]
public IActionResult DeleteUser() {
_users.DeleteUser(_context.UserId);
return Ok();
}
}

View File

@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using ProjectManager.Backend.Entities;
using ProjectManager.Backend.Options;
namespace ProjectManager.Backend;
public class DatabaseContext : DbContext {
private readonly GeneralOptions _options;
public DbSet<User> Users { get; set; }
public DbSet<UserToken> Tokens { get; set; }
public DbSet<Project> Projects { get; set; }
public DatabaseContext(IOptions<GeneralOptions> options) {
_options = options.Value;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
optionsBuilder.UseMySQL(_options.Database);
}
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<User>(entry => {
entry.HasKey(e => e.UserId);
entry.Property(e => e.Email);
entry.Property(e => e.Username);
entry.Property(e => e.Password);
entry.Property(e => e.MaxProjects);
});
modelBuilder.Entity<UserToken>(entry => {
entry.HasKey(e => e.TokenId);
entry.Property(e => e.UserId);
entry.Property(e => e.ClientIp);
entry.Property(e => e.Created);
});
modelBuilder.Entity<Project>(entry => {
entry.HasKey(e => e.ProjectId);
entry.Property(e => e.OwnerId);
entry.Property(e => e.Name);
entry.Property(e => e.Port);
entry.Property(e => e.ContainerName);
});
}
}

View File

@@ -0,0 +1,20 @@
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["ProjectManager.Backend.csproj", ""]
RUN dotnet restore "ProjectManager.Backend.csproj"
COPY . .
WORKDIR "/src"
RUN dotnet build "ProjectManager.Backend.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "ProjectManager.Backend.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ProjectManager.Backend.dll"]

View File

@@ -0,0 +1,15 @@
namespace ProjectManager.Backend.Entities;
public class Project {
public string ProjectId { get; set; }
public string OwnerId { get; set; }
public string Name { get; set; }
public int Port { get; set; }
public string ContainerName { get; set; }
public int ProxyId { get; set; } = -1;
public int CertificateId { get; set; } = -1;
}
public class ProjectEdit {
public string Name { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace ProjectManager.Backend.Entities;
public class User {
public string UserId { get; set; }
public string Email { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public int MaxProjects { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace ProjectManager.Backend.Entities;
public class UserToken {
public string TokenId { get; set; }
public string UserId { get; set; }
public string ClientIp { get; set; }
public DateTime Created { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace ProjectManager.Backend.Options;
public class GeneralOptions : OptionsFromConfiguration {
public override string Position { get; set; } = "General";
public string Database { get; set; }
public int TokenTimeInDays { get; set; }
public string PortRange { get; set; }
public string DockerPath { get; set; }
public string Root { get; set; }
public string PocketBaseVersion { get; set; }
public int MaxProjects { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace ProjectManager.Backend.Options;
public abstract class OptionsFromConfiguration {
public abstract string Position { get; set; }
}
public static class OptionsFromConfigurationExtensions {
public static T AddOptionsFromConfiguration<T>(this IServiceCollection services, IConfiguration configuration) where T : OptionsFromConfiguration {
T optionsInstance = (T)Activator.CreateInstance(typeof(T));
if (optionsInstance == null) return null;
var position = optionsInstance.Position;
services.Configure((Action<T>)(options => {
configuration.Bind(position, options);
}));
return optionsInstance;
}
}

View File

@@ -0,0 +1,12 @@
namespace ProjectManager.Backend.Options;
public class ProxyOptions : OptionsFromConfiguration {
public override string Position { get; set; } = "Proxy";
public bool Enable { get; set; }
public string Url { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public string Domain { get; set; }
public string Host { get; set; }
}

View File

@@ -0,0 +1,49 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using ProjectManager.Backend;
using ProjectManager.Backend.Options;
using ProjectManager.Backend.Security;
using ProjectManager.Backend.Apis;
var builder = WebApplication.CreateBuilder(args);
// Add options to the container
builder.Configuration.AddEnvironmentVariables();
builder.Services.AddOptionsFromConfiguration<GeneralOptions>(builder.Configuration);
builder.Services.AddOptionsFromConfiguration<ProxyOptions>(builder.Configuration);
// Add services to the container.
builder.Services.AddDbContext<DatabaseContext>();
builder.Services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
builder.Services.AddScoped<ITokenContext, TokenContext>();
builder.Services.AddScoped<IUserApi, UserApi>();
builder.Services.AddScoped<ITokenApi, TokenApi>();
builder.Services.AddScoped<IProjectApi, ProjectApi>();
builder.Services.AddScoped<IDockerApi, DockerApi>();
builder.Services.AddScoped<IProxyApi, ProxyApi>();
builder.Services.AddCors();
builder.Services.AddCustomAuthentication(true);
builder.Services.AddControllers();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) {
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors(
options => options
.WithOrigins(new []{app.Configuration.GetSection("Frontend").Get<string>() ?? ""})
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
);
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Docker.DotNet" Version="3.125.12" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.0" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="6.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,41 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:47680",
"sslPort": 44387
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5110",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7083;http://localhost:5110",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": false,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,86 @@
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using ProjectManager.Backend.Apis;
namespace ProjectManager.Backend.Security;
public static class AuthenticationExtensions {
public static AuthenticationBuilder AddCustomAuthentication(this IServiceCollection services, bool configureSwagger = false) {
var builder = services
.AddAuthentication("CustomScheme")
.AddScheme<AuthenticationSchemeOptions, AuthenticationHandler>("CustomScheme", _ => { });
if (configureSwagger) {
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(c => {
c.AddSecurityDefinition("CustomScheme", new OpenApiSecurityScheme {
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "CustomScheme"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement {{
new OpenApiSecurityScheme {
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "CustomScheme"
},
Scheme = "oauth2",
Name = "CustomScheme",
In = ParameterLocation.Header,
},
ArraySegment<string>.Empty
}});
});
}
return builder;
}
}
public sealed class AuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> {
private readonly ITokenApi _tokens;
public AuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
ITokenApi tokens)
: base(options, logger, encoder, clock) {
_tokens = tokens;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() {
string tokenId = Request.Headers["Authorization"];
if (string.IsNullOrEmpty(tokenId)) tokenId = Request.Query["token"];
string clientIp = Context.Connection.RemoteIpAddress?.ToString();
return !_tokens.ValidateToken(tokenId, clientIp) ? AuthenticateResult.Fail("Token invalid") : AuthenticateResult.Success(GenerateTicket(tokenId));
}
private AuthenticationTicket GenerateTicket(string tokenId) {
var user = _tokens.GetUserFromToken(tokenId);
var claims = new List<Claim> {
new("TokenId", tokenId),
new("UserId", user.UserId)
};
var principal = new ClaimsPrincipal();
principal.AddIdentity(new ClaimsIdentity(claims, Scheme.Name));
return new AuthenticationTicket(principal, Scheme.Name);
}
}
public static class ClaimsPrincipalExtensions {
public static string GetTokenId(this ClaimsPrincipal principal) => principal.FindFirstValue("TokenId");
public static string GetUserId(this ClaimsPrincipal principal) => principal.FindFirstValue("UserId");
}

View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ProjectManager.Backend.Security;
public sealed class AuthorizedAttribute : TypeFilterAttribute {
public AuthorizedAttribute(params string[] permission) : base(typeof(AuthorizationHandler)) {
Arguments = new object[] { permission };
}
}
public sealed class AuthorizationHandler : IAuthorizationFilter {
private readonly string[] _permissions;
public AuthorizationHandler(params string[] permissions) {
_permissions = permissions;
}
public void OnAuthorization(AuthorizationFilterContext context) {
if (context.Filters.Any(item => item is IAllowAnonymousFilter)) return;
if (context.HttpContext.User.Identity?.IsAuthenticated != true) {
context.Result = new UnauthorizedResult();
return;
}
//TODO: Handle Permissions
}
}

View File

@@ -0,0 +1,19 @@
namespace ProjectManager.Backend.Security;
public interface ITokenContext {
public bool IsAuthenticated { get; }
public string TokenId { get; }
public string UserId { get; }
}
public sealed class TokenContext : ITokenContext {
private readonly IHttpContextAccessor _accessor;
public bool IsAuthenticated => _accessor.HttpContext?.User.Identity?.IsAuthenticated == true;
public string TokenId => _accessor.HttpContext?.User.GetTokenId();
public string UserId => _accessor.HttpContext?.User.GetUserId();
public TokenContext(IHttpContextAccessor accessor) {
_accessor = accessor;
}
}

View File

@@ -0,0 +1,28 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"ProjectManager.Backend.Security.AuthenticationHandler": "None"
}
},
"AllowedHosts": ["http://localhost:5110"],
"Frontend": null,
"General": {
"TokenTimeInDays": 30,
"Database": null,
"PortRange": "24400-24599",
"DockerPath": "unix:///var/run/docker.sock",
"Root": "./projects/",
"PocketBaseVersion": "latest",
"MaxProjects": 3
},
"Proxy": {
"Enable": false,
"Url": null,
"Email": null,
"Password": null,
"Domain": null,
"Host": null
}
}