Archived
Private
Public Access
1
0

Initial commit

This commit is contained in:
2022-09-04 12:03:44 +02:00
commit 15f48d259f
91 changed files with 22716 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="dataSourceStorageLocal" created-in="RD-222.3962.23">
<data-source name="WebDesktop" uuid="95aba07a-0fe8-4ac6-bdce-406f8acafcd0">
<database-info product="MariaDB" version="10.3.34-MariaDB-0+deb10u1" jdbc-version="4.2" driver-name="MariaDB Connector/J" driver-version="2.7.3" dbms="MARIADB" exact-version="10.3.34" exact-driver-version="2.7">
<extra-name-characters>#@</extra-name-characters>
<identifier-quote-string>`</identifier-quote-string>
</database-info>
<case-sensitivity plain-identifiers="exact" quoted-identifiers="exact" />
<secret-storage>master_key</secret-storage>
<user-name>WebDesktop</user-name>
<schema-mapping>
<introspection-scope>
<node kind="schema" qname="@" />
</introspection-scope>
</schema-mapping>
</data-source>
</component>
</project>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="WebDesktop" uuid="95aba07a-0fe8-4ac6-bdce-406f8acafcd0">
<driver-ref>mariadb</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mariadb://213.136.89.237:3306/WebDesktop</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<dataSource name="WebDesktop">
<database-model serializer="dbm" dbms="MARIADB" family-id="MARIADB" format-version="4.43">
<root id="1">
<DefaultCasing>exact</DefaultCasing>
<ServerVersion>10.3.34</ServerVersion>
</root>
<schema id="2" parent="1" name="WebDesktop">
<Current>1</Current>
</schema>
<schema id="3" parent="1" name="information_schema"/>
</database-model>
</dataSource>

View File

@@ -0,0 +1,2 @@
#n:information_schema
!<md> [null, 0, null, null, -2147483648, -2147483648]

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectDictionaryState">
<dictionary name="leon" />
</component>
</project>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders>
<Path>Frontend</Path>
</attachedFolders>
<explicitIncludes />
<explicitExcludes />
</component>
</project>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<enabledExtensions>
<entry key="MermaidLanguageExtension" value="false" />
<entry key="PlantUMLLanguageExtension" value="false" />
</enabledExtensions>
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="com.jetbrains.rider.android.RiderAndroidMiscFileCreationComponent">
<option name="ENSURE_MISC_FILE_EXISTS" value="true" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RiderProjectSettingsUpdater">
<option name="vcsConfiguration" value="2" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/Backend/DatabaseContext.cs" dialect="GenericSQL" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoGeneratedRunConfigurationManager">
<projectFile profileName="Backend">Backend/Backend.csproj</projectFile>
<projectFile profileName="IIS Express">Backend/Backend.csproj</projectFile>
</component>
<component name="ChangeListManager">
<list default="true" id="041c7675-58ae-4243-af88-0d29855e558f" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.idea/.idea.WebDesktop 2.0/.idea/vcs.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.WebDesktop 2.0/.idea/vcs.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/.idea.WebDesktop 2.0/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.WebDesktop 2.0/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Backend/Controllers/UserController.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Backend/Controllers/UserController.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Backend/Security/Permissions.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Backend/Security/Permissions.cs" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="TypeScript File" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/123735509fa19d358d3971345e1872b6d26687d1b343ad7ca37af43930694c/Guid.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/efd3e9c4749621d4a53b45d7117d2a581e02a265c444a040f1387a155db/Dictionary.cs" root0="SKIP_HIGHLIGHTING" />
</component>
<component name="MarkdownSettingsMigration">
<option name="stateVersion" value="1" />
</component>
<component name="ProjectId" id="2E2lWgCQKiR5ds6nCyp7WbN5Oq7" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true">
<ConfirmationsSetting value="2" id="Add" />
</component>
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ASKED_ADD_EXTERNAL_FILES": "true",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
"WebServerToolWindowFactoryState": "false",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings",
"ts.external.directory.path": "D:\\Programmierstuff\\Projekte\\WebDesktop 2.0\\Frontend\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true"
},
"keyToStringList": {
"DatabaseDriversLRU": [
"mariadb"
]
}
}]]></component>
<component name="RunManager" selected=".NET Launch Settings Profile.Backend">
<configuration name="Backend" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Backend/Backend.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net6.0" />
<option name="LAUNCH_PROFILE_NAME" value="Backend" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="Start Containers" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="docker-compose up" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="" />
<option name="SCRIPT_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="false" />
<envs />
<method v="2" />
</configuration>
<configuration name="Frontend" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/Frontend/package.json" />
<command value="run" />
<scripts>
<script value="start" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
<configuration name="Test" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/Frontend/package.json" />
<command value="run" />
<scripts>
<script value="test" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
<list>
<item itemvalue=".NET Launch Settings Profile.Backend" />
<item itemvalue="npm.Test" />
<item itemvalue="npm.Frontend" />
<item itemvalue="Shell Script.Start Containers" />
</list>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="21" Folder0="D:\Spelling\de-DE" Folder1="D:\Spelling\de-DE" Folder2="D:\Spelling\de-DE" Folder3="D:\Spelling\de-DE" Folder4="D:\Spelling\de-DE" Folder5="D:\Spelling\de-DE" Folder6="D:\Spelling\de-DE" Folder7="D:\Spelling\de-DE" Folder8="D:\Spelling\de-DE" Folder9="D:\Spelling\de-DE" Folder10="D:\Spelling\de-DE" Folder11="D:\Spelling\de-DE" Folder12="D:\Spelling\de-DE" Folder13="D:\Spelling\de-DE" Folder14="D:\Spelling\de-DE" Folder15="D:\Spelling\de-DE" Folder16="D:\Spelling\de-DE" Folder17="D:\Spelling\de-DE" Folder18="D:\Spelling\de-DE" Folder19="D:\Spelling\de-DE" Folder20="D:\Spelling\de-DE" CustomDictionaries="21" CustomDictionary0="D:\Spelling\de-DE\abkuerzungen.dic" CustomDictionary1="D:\Spelling\de-DE\astronomie.dic" CustomDictionary2="D:\Spelling\de-DE\biologie.dic" CustomDictionary3="D:\Spelling\de-DE\chemie.dic" CustomDictionary4="D:\Spelling\de-DE\computer.dic" CustomDictionary5="D:\Spelling\de-DE\de_alt.dic" CustomDictionary6="D:\Spelling\de-DE\de_neu.dic" CustomDictionary7="D:\Spelling\de-DE\elektronic.dic" CustomDictionary8="D:\Spelling\de-DE\geographie.dic" CustomDictionary9="D:\Spelling\de-DE\geologie.dic" CustomDictionary10="D:\Spelling\de-DE\informatik.dic" CustomDictionary11="D:\Spelling\de-DE\mathematik.dic" CustomDictionary12="D:\Spelling\de-DE\medizin.dic" CustomDictionary13="D:\Spelling\de-DE\namen.dic" CustomDictionary14="D:\Spelling\de-DE\organisationen.dic" CustomDictionary15="D:\Spelling\de-DE\physik.dic" CustomDictionary16="D:\Spelling\de-DE\recht.dic" CustomDictionary17="D:\Spelling\de-DE\remove.dic" CustomDictionary18="D:\Spelling\de-DE\tex_de.dic" CustomDictionary19="D:\Spelling\de-DE\vornamen.dic" CustomDictionary20="D:\Spelling\de-DE\wirtschaft.dic" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="041c7675-58ae-4243-af88-0d29855e558f" name="Changes" comment="" />
<created>1661801592204</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1661801592204</updated>
<workItem from="1661801603705" duration="2784000" />
<workItem from="1661876909316" duration="8486000" />
<workItem from="1662126115999" duration="9904000" />
<workItem from="1662204907636" duration="15449000" />
<workItem from="1662228779224" duration="302000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
<component name="UnityUnitTestConfiguration" currentTestLauncher="NUnit" />
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<ignored-roots>
<path value="$PROJECT_DIR$/../.." />
</ignored-roots>
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
</component>
</project>

25
Backend/.dockerignore Normal file
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

2
Backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
obj
bin

14
Backend/Backend.csproj Normal file
View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MySql.EntityFrameworkCore" Version="6.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Mvc;
namespace Backend.Controllers;
[ApiController]
public class TestController : ControllerBase {
private DatabaseContext _context;
public TestController(DatabaseContext context) {
_context = context;
}
[HttpGet("")]
public IActionResult InitializeDb() {
_context.ExecuteTableCreation();
return Ok("OK");
}
}

View File

@@ -0,0 +1,98 @@
using Backend.Entitys;
using Backend.Logic;
using Backend.LogicResults;
using Backend.Security;
using Backend.Security.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Backend.Controllers;
[ApiController]
[Route("users")]
public class UserController : ControllerBase {
private readonly UserLogic _logic;
private readonly ITokenContext _context;
public UserController(UserLogic logic, ITokenContext context) {
_logic = logic;
_context = context;
}
[HttpGet]
[Authorized(Permissions.ShowUsers)]
public ActionResult<IEnumerable<User>> GetUsers() {
return this.FromLogicResult(_logic.GetUsers());
}
[HttpPut("login")]
public ActionResult<AccessToken> Login([FromBody] UserLogin login) {
return this.FromLogicResult(_logic.Login(login));
}
[HttpPost("register")]
public ActionResult<AccessToken> Register([FromBody] UserEditor editor) {
return this.FromLogicResult(_logic.Register(editor));
}
[HttpGet("token")]
[Authorized]
public ActionResult<AccessToken> GetToken() {
return this.FromLogicResult(_logic.GenerateToken(_logic.GetCurrentUserRefreshToken()));
}
[HttpGet("{userId}")]
[Authorized(Permissions.ShowUsers)]
public ActionResult<User> GetUser(Guid userId) {
return this.FromLogicResult(_logic.GetUser(userId));
}
[HttpGet("self")]
[Authorized]
public ActionResult<User> GetOwnUser() {
return this.FromLogicResult(_logic.GetUser(_context.UserId));
}
[HttpPut("{userId}")]
[Authorized(Permissions.EditUsers)]
public ActionResult EditUser(Guid userId, [FromBody] UserEditor editor) {
return this.FromLogicResult(_logic.EditUser(userId, editor));
}
[HttpDelete("{userId}")]
[Authorized(Permissions.DeleteUsers)]
public ActionResult DeleteUser(Guid userId) {
return this.FromLogicResult(_logic.DeleteUser(userId));
}
[HttpGet("{userId}/permissions")]
[Authorized(Permissions.ShowUserPermissions)]
public ActionResult<IEnumerable<string>> GetUserPermissions(Guid userId) {
return this.FromLogicResult(_logic.GetPermissions(userId));
}
[HttpGet("{userId}/permissions/raw")]
[Authorized(Permissions.ShowUserPermissions)]
public ActionResult<IEnumerable<string>> GetUserPermissionsRaw(Guid userId) {
return this.FromLogicResult(_logic.GetPermissionsRaw(userId));
}
[HttpPost("{userId}/permissions")]
[Authorized(Permissions.EditUserPermissions, Permissions.EditOwnPermissions)]
public ActionResult AddUserPermissions(Guid userId, [FromBody] string[] permissions) {
return this.FromLogicResult(_logic.AddPermissions(userId, permissions));
}
[HttpPut("{userId}/permissions")]
[Authorized(Permissions.EditUserPermissions, Permissions.EditOwnPermissions)]
public ActionResult DeleteUserPermissions(Guid userId, [FromBody] string[] permissions) {
return this.FromLogicResult(_logic.DeletePermissions(userId, permissions));
}
[HttpDelete("{userId}/logout")]
[Authorized(Permissions.LogoutUsers)]
public ActionResult Logout(Guid userId) {
return this.FromLogicResult(_logic.Logout(userId));
}
}

View File

@@ -0,0 +1,65 @@
using Backend.Entitys;
using Microsoft.EntityFrameworkCore;
namespace Backend;
public class DatabaseContext : DbContext {
private string _connectionString;
public DbSet<User> Users { get; set; }
public DbSet<RefreshToken> RefreshTokens { get; set; }
public DbSet<AccessToken> AccessTokens { get; set; }
public DbSet<Permission> Permissions { get; set; }
public DatabaseContext(IConfiguration configuration) {
_connectionString = configuration.GetSection("MySQL").Get<string>();
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
if (string.IsNullOrEmpty(_connectionString))
throw new ArgumentException("MySQL Connection String was not defined correctly in the Configuration!");
optionsBuilder.UseMySQL(_connectionString);
}
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<User>(entry => {
entry.HasKey(e => e.Id);
entry.Property(e => e.FirstName);
entry.Property(e => e.LastName);
entry.Property(e => e.Email);
entry.Property(e => e.Username);
entry.Property(e => e.Password);
entry.Property(e => e.Created);
});
modelBuilder.Entity<RefreshToken>(entry => {
entry.HasKey(e => e.Id);
entry.Property(e => e.UserId);
entry.Property(e => e.ExpirationDate);
});
modelBuilder.Entity<AccessToken>(entry => {
entry.HasKey(e => e.Id);
entry.Property(e => e.RefreshTokenId);
entry.Property(e => e.ExpirationDate);
});
modelBuilder.Entity<Permission>(entry => {
entry.HasKey(e => e.Id);
entry.Property(e => e.Id).ValueGeneratedOnAdd();
entry.Property(e => e.UserId);
entry.Property(e => e.PermissionKey);
});
}
public void ExecuteTableCreation() {
Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS Users (Id VARCHAR(50) PRIMARY KEY, FirstName VARCHAR(255), LastName VARCHAR(255), Email VARCHAR(255), Username VARCHAR(255), Password VARCHAR(255), Created TIMESTAMP)");
Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS RefreshTokens (Id VARCHAR(50) PRIMARY KEY, UserId VARCHAR(50), ExpirationDate TIMESTAMP)");
Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS AccessTokens (Id VARCHAR(50) PRIMARY KEY, RefreshTokenId VARCHAR(50), ExpirationDate TIMESTAMP)");
Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS Permissions (Id INT PRIMARY KEY AUTO_INCREMENT, UserId VARCHAR(50), PermissionName VARCHAR(100))");
}
}

20
Backend/Dockerfile Normal file
View File

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

View File

@@ -0,0 +1,16 @@
using System;
namespace Backend.Entitys;
public class Permission {
public int Id { get; set; }
public Guid UserId { get; set; }
public string PermissionKey { get; set; }
}
public class PermissionGroup {
public string Permission { get; set; }
public string Name { get; set; }
public string[] Permissions { get; set; }
public string[] Inherits { get; set; }
}

20
Backend/Entitys/Tokens.cs Normal file
View File

@@ -0,0 +1,20 @@
using System;
namespace Backend.Entitys;
public class Tokens {
public AccessToken AccessToken { get; set; }
public RefreshToken RefreshToken { get; set; }
}
public class RefreshToken {
public Guid Id { get; set; }
public Guid UserId { get; set; }
public DateTime ExpirationDate { get; set; }
}
public class AccessToken {
public Guid Id { get; set; }
public Guid RefreshTokenId { get; set; }
public DateTime ExpirationDate { get; set; }
}

21
Backend/Entitys/User.cs Normal file
View File

@@ -0,0 +1,21 @@
using System;
namespace Backend.Entitys;
public class User : UserEditor {
public Guid Id { get; set; }
public DateTime Created { get; set; }
}
public class UserLogin {
public string UsernameOrEmail { get; set; }
public string Password { get; set; }
}
public class UserEditor {
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string Username { get; set; }
public string Password { get; set; }
}

213
Backend/Logic/UserLogic.cs Normal file
View File

@@ -0,0 +1,213 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Backend.Entitys;
using Backend.LogicResults;
using Backend.Options;
using Backend.Repositorys;
using Backend.Security;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Backend.Logic;
public class UserLogic {
private UserRepository _users;
private TokenRepository _tokens;
private GroupRepository _groups;
private UserMessageOptions _messages;
private IHttpContextAccessor _contextAccessor;
private ITokenContext _context;
public UserLogic(
UserRepository users,
TokenRepository tokens,
GroupRepository groups,
IOptions<UserMessageOptions> messages,
IHttpContextAccessor contextAccessor,
ITokenContext context) {
_users = users;
_tokens = tokens;
_groups = groups;
_messages = messages.Value;
_contextAccessor = contextAccessor;
_context = context;
}
public ILogicResult<IEnumerable<User>> GetUsers() {
var users = _users.GetUsers()
.Select(user => new User {
Id = user.Id,
Created = user.Created,
Email = user.Email,
FirstName = user.FirstName,
LastName = user.LastName,
Username = user.Username
});
return LogicResult<IEnumerable<User>>.Ok(users);
}
public ILogicResult<User> GetUser(Guid userId) {
var user = _users.GetUser(userId);
if (user is null) return LogicResult<User>.NotFound(_messages.NotFound);
return LogicResult<User>.Ok(new User {
Id = user.Id,
Created = user.Created,
Email = user.Email,
FirstName = user.FirstName,
LastName = user.LastName,
Username = user.Username
});
}
public ILogicResult EditUser(Guid userId, UserEditor editor) {
if (!ValidateEdit(editor)) return LogicResult.BadRequest(_messages.InvalidEditData);
if (_users.GetUser(userId) is null) return LogicResult.NotFound(_messages.NotFound);
_users.EditUser(userId, editor);
return LogicResult.Ok();
}
public ILogicResult DeleteUser(Guid userId) {
if (_users.GetUser(userId) is null) return LogicResult.NotFound(_messages.NotFound);
_tokens.DeleteUserTokens(userId);
_users.DeleteUser(userId);
return LogicResult.Ok();
}
public ILogicResult<IEnumerable<string>> GetPermissions(Guid userId) {
if (_users.GetUser(userId) is null) return LogicResult<IEnumerable<string>>.NotFound(_messages.NotFound);
return LogicResult<IEnumerable<string>>.Ok(_groups.GetUserPermissions(userId).Select(perm => perm.PermissionKey));
}
public ILogicResult<IEnumerable<string>> GetPermissionsRaw(Guid userId) {
if (_users.GetUser(userId) is null) return LogicResult<IEnumerable<string>>.NotFound(_messages.NotFound);
return LogicResult<IEnumerable<string>>.Ok(_groups.GetUserPermissionsRaw(userId).Select(perm => perm.PermissionKey));
}
public ILogicResult AddPermissions(Guid userId, params string[] permissions) {
if (_users.GetUser(userId) is null) return LogicResult.NotFound(_messages.NotFound);
_groups.AddPermissions(userId, permissions);
return LogicResult.Ok();
}
public ILogicResult DeletePermissions(Guid userId, params string[] permissions) {
if (_users.GetUser(userId) is null) return LogicResult.NotFound(_messages.NotFound);
_groups.DeletePermissions(userId, permissions);
return LogicResult.Ok();
}
public ILogicResult<AccessToken> Login(UserLogin login) {
var user = _users.GetUsers().SingleOrDefault(user =>
user.Username == login.UsernameOrEmail || user.Email == login.UsernameOrEmail);
if (user is null) return LogicResult<AccessToken>.NotFound(_messages.NotFound);
if (user.Password != TokenRepository.Hash128(login.Password, user.Email))
return LogicResult<AccessToken>.BadRequest(_messages.WrongPassword);
_tokens.DeleteUserTokens(user.Id);
var refreshToken = _tokens.CreateRefreshToken(user.Id);
var accessToken = _tokens.CreateAccessToken(refreshToken.Id);
SetRefreshToken(refreshToken);
return LogicResult<AccessToken>.Ok(accessToken);
}
public ILogicResult<AccessToken> Register(UserEditor editor) {
var users = _users.GetUsers();
if (users.Any(user => user.Email == editor.Email || user.Username == editor.Username))
return LogicResult<AccessToken>.Conflict(_messages.UsernameOrEmailExist);
if (!ValidateUserdata(editor))
return LogicResult<AccessToken>.BadRequest(_messages.InvalidRegisterData);
var user = _users.CreateUser(editor);
var refreshToken = _tokens.CreateRefreshToken(user.Id);
var accessToken = _tokens.CreateAccessToken(refreshToken.Id);
SetRefreshToken(refreshToken);
return LogicResult<AccessToken>.Ok(accessToken);
}
public ILogicResult Logout(Guid userId) {
if (_users.GetUser(userId) is null) return LogicResult.NotFound(_messages.NotFound);
_tokens.DeleteUserTokens(userId);
if (_context.UserId == userId) DeleteRefreshToken();
return LogicResult.Ok();
}
public ILogicResult<AccessToken> GenerateToken(Guid refreshTokenId) {
if (!_tokens.ValidateRefreshToken(refreshTokenId)) return LogicResult<AccessToken>.Conflict(_messages.InvalidRefreshToken);
var token = _tokens.GetAccessTokens(refreshTokenId).ToArray().FirstOrDefault(token => _tokens.ValidateAccessToken(token.Id));
if (token is not null) return LogicResult<AccessToken>.Ok(token);
return LogicResult<AccessToken>.Ok(_tokens.CreateAccessToken(refreshTokenId));
}
public Guid GetCurrentUserRefreshToken() {
var token = _contextAccessor.HttpContext?.Request.Cookies["refresh_token"];
if (token == null) return Guid.Empty;
return Guid.Parse(token);
}
private bool ValidateUserdata(UserEditor editor) {
if (string.IsNullOrEmpty(editor.FirstName)) return false;
if (string.IsNullOrEmpty(editor.LastName)) return false;
if (string.IsNullOrEmpty(editor.Email)) return false;
if (string.IsNullOrEmpty(editor.Username)) return false;
if (string.IsNullOrEmpty(editor.Password)) return false;
if (editor.FirstName.Length > 255) return false;
if (editor.LastName.Length > 255) return false;
if (editor.Email.Length > 255) return false;
if (editor.Username.Length > 255) return false;
if (editor.Password.Length > 255) return false;
if (!editor.Email.Contains('@') || !editor.Email.Contains('.')) return false;
if (editor.Username.Contains('@')) return false;
if (editor.Password.Length < 8) return false;
return true;
}
private bool ValidateEdit(UserEditor editor) {
if (editor.FirstName?.Length > 255) return false;
if (editor.LastName?.Length > 255) return false;
if (editor.Email?.Length > 255) return false;
if (editor.Username?.Length > 255) return false;
if (editor.Password?.Length > 255) return false;
if (!string.IsNullOrEmpty(editor.Email)) {
if (!editor.Email.Contains('@') || !editor.Email.Contains('.')) return false;
}
if (!string.IsNullOrEmpty(editor.Username)) {
if (editor.Username.Contains('@')) return false;
}
if (!string.IsNullOrEmpty(editor.Password)) {
if (editor.Password.Length < 8) return false;
}
return true;
}
private void DeleteRefreshToken() {
_contextAccessor.HttpContext?.Response.Cookies.Delete("refresh_token");
}
private void SetRefreshToken(RefreshToken token) {
_contextAccessor.HttpContext?.Response.Cookies.Append("refresh_token", token.Id.ToString(), new CookieOptions {
MaxAge = token.ExpirationDate - DateTime.Now,
HttpOnly = true,
Secure = true
});
}
}

View File

@@ -0,0 +1,51 @@
using System;
using System.Net;
using Microsoft.AspNetCore.Mvc;
namespace Backend.LogicResults {
public static class ControllerBaseExtention {
public static ActionResult FromLogicResult(this ControllerBase controller, ILogicResult result) {
switch (result.State) {
case LogicResultState.Ok:
return controller.Ok();
case LogicResultState.BadRequest:
return controller.StatusCode((int)HttpStatusCode.BadRequest, result.Message);
case LogicResultState.Forbidden:
return controller.StatusCode((int)HttpStatusCode.Forbidden, result.Message);
case LogicResultState.NotFound:
return controller.StatusCode((int)HttpStatusCode.NotFound, result.Message);
case LogicResultState.Conflict:
return controller.StatusCode((int)HttpStatusCode.Conflict, result.Message);
default:
throw new Exception("An unhandled result has occurred as a result of a service call.");
}
}
public static ActionResult FromLogicResult<T>(this ControllerBase controller, ILogicResult<T> result) {
switch (result.State) {
case LogicResultState.Ok:
return controller.Ok(result.Data);
case LogicResultState.BadRequest:
return controller.StatusCode((int)HttpStatusCode.BadRequest, result.Message);
case LogicResultState.Forbidden:
return controller.StatusCode((int)HttpStatusCode.Forbidden, result.Message);
case LogicResultState.NotFound:
return controller.StatusCode((int)HttpStatusCode.NotFound, result.Message);
case LogicResultState.Conflict:
return controller.StatusCode((int)HttpStatusCode.Conflict, result.Message);
default:
throw new Exception("An unhandled result has occurred as a result of a service call.");
}
}
}
}

View File

@@ -0,0 +1,19 @@
namespace Backend.LogicResults {
public interface ILogicResult {
LogicResultState State { get; set; }
string Message { get; set; }
bool IsSuccessful { get; }
}
public interface ILogicResult<T> {
LogicResultState State { get; set; }
T Data { get; set; }
string Message { get; set; }
bool IsSuccessful { get; }
}
}

View File

@@ -0,0 +1,170 @@
namespace Backend.LogicResults {
internal class LogicResult : ILogicResult {
public LogicResultState State { get; set; }
public string Message { get; set; }
public bool IsSuccessful => State == LogicResultState.Ok;
public static LogicResult Ok() {
return new LogicResult() {
State = LogicResultState.Ok
};
}
public static LogicResult BadRequest() {
return new LogicResult() {
State = LogicResultState.BadRequest
};
}
public static LogicResult BadRequest(string message) {
return new LogicResult() {
State = LogicResultState.BadRequest,
Message = message
};
}
public static LogicResult Forbidden() {
return new LogicResult() {
State = LogicResultState.Forbidden
};
}
public static LogicResult Forbidden(string message) {
return new LogicResult() {
State = LogicResultState.Forbidden,
Message = message
};
}
public static LogicResult NotFound() {
return new LogicResult() {
State = LogicResultState.NotFound
};
}
public static LogicResult NotFound(string message) {
return new LogicResult() {
State = LogicResultState.NotFound,
Message = message
};
}
public static LogicResult Conflict() {
return new LogicResult() {
State = LogicResultState.Conflict
};
}
public static LogicResult Conflict(string message) {
return new LogicResult() {
State = LogicResultState.Conflict,
Message = message
};
}
public static LogicResult Forward(LogicResult result) {
return new LogicResult() {
State = result.State,
Message = result.Message
};
}
public static LogicResult Forward<T>(ILogicResult<T> result) {
return new LogicResult() {
State = result.State,
Message = result.Message
};
}
}
internal class LogicResult<T> : ILogicResult<T> {
public LogicResultState State { get; set; }
public T Data { get; set; }
public string Message { get; set; }
public bool IsSuccessful => State == LogicResultState.Ok;
public static LogicResult<T> Ok() {
return new LogicResult<T>() {
State = LogicResultState.Ok
};
}
public static LogicResult<T> Ok(T result) {
return new LogicResult<T>() {
State = LogicResultState.Ok,
Data = result
};
}
public static LogicResult<T> BadRequest() {
return new LogicResult<T>() {
State = LogicResultState.BadRequest
};
}
public static LogicResult<T> BadRequest(string message) {
return new LogicResult<T>() {
State = LogicResultState.BadRequest,
Message = message
};
}
public static LogicResult<T> Forbidden() {
return new LogicResult<T>() {
State = LogicResultState.Forbidden
};
}
public static LogicResult<T> Forbidden(string message) {
return new LogicResult<T>() {
State = LogicResultState.Forbidden,
Message = message
};
}
public static LogicResult<T> NotFound() {
return new LogicResult<T>() {
State = LogicResultState.NotFound
};
}
public static LogicResult<T> NotFound(string message) {
return new LogicResult<T>() {
State = LogicResultState.NotFound,
Message = message
};
}
public static LogicResult<T> Conflict() {
return new LogicResult<T>() {
State = LogicResultState.Conflict
};
}
public static LogicResult<T> Conflict(string message) {
return new LogicResult<T>() {
State = LogicResultState.Conflict,
Message = message
};
}
public static LogicResult<T> Forward(ILogicResult result) {
return new LogicResult<T>() {
State = result.State,
Message = result.Message
};
}
public static LogicResult<T> Forward<T2>(ILogicResult<T2> result) {
return new LogicResult<T>() {
State = result.State,
Message = result.Message
};
}
}
}

View File

@@ -0,0 +1,9 @@
namespace Backend.LogicResults {
public enum LogicResultState {
Ok,
BadRequest,
Forbidden,
NotFound,
Conflict
}
}

View File

@@ -0,0 +1,12 @@
namespace Backend.Options;
public class UserMessageOptions : OptionsFromConfiguration {
public override string Position => "Messages:Users";
public string NotFound { get; set; }
public string InvalidEditData { get; set; }
public string InvalidRegisterData { get; set; }
public string WrongPassword { get; set; }
public string UsernameOrEmailExist { get; set; }
public string InvalidRefreshToken { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Backend.Options;
public class UserOptions : OptionsFromConfiguration {
public override string Position => "Users";
public string[] DefaultPermissions { get; set; }
}

79
Backend/Program.cs Normal file
View File

@@ -0,0 +1,79 @@
using Backend;
using Backend.Logic;
using Backend.Options;
using Backend.Repositorys;
using Backend.Security;
using Backend.Security.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<DatabaseContext>();
builder.Services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
builder.Services.AddScoped<ITokenContext, TokenContext>();
builder.Services.AddScoped<TokenRepository>();
builder.Services.AddScoped<GroupRepository>();
builder.Services.AddScoped<UserRepository>();
builder.Services.AddScoped<UserLogic>();
builder.Services.AddOptionsFromConfiguration<JwtTokenAuthenticationOptions>(builder.Configuration);
builder.Services.AddOptionsFromConfiguration<UserOptions>(builder.Configuration);
builder.Services.AddOptionsFromConfiguration<UserMessageOptions>(builder.Configuration);
builder.Services.AddCors();
builder.Services.AddAuthentication(JwtTokenAuthentication.Scheme).AddJwtTokenAuthentication(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => {
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {
Description = @"JWT Authorization header using the Bearer scheme. \r\n\r\n
Enter 'Bearer' [space] and then your token in the text input below.",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement {{
new OpenApiSecurityScheme {
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
},
Scheme = "oauth2",
Name = "Bearer",
In = ParameterLocation.Header,
},
ArraySegment<string>.Empty
}});
});
var app = builder.Build();
GroupRepository.CompileGroups(app.Configuration);
if (app.Environment.IsDevelopment()) {
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors(
options => options
.WithOrigins(app.Configuration.GetSection("Origins").Get<string[]>())
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
);
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseWebSockets();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:42992",
"sslPort": 44301
}
},
"profiles": {
"Backend": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,94 @@
using Backend.Entitys;
namespace Backend.Repositorys;
public class GroupRepository {
public static PermissionGroup[] Groups;
public static void CompileGroups(IConfiguration configuration) {
var groupsSections = configuration.GetSection("Groups").GetChildren();
List<PermissionGroup> groups = new List<PermissionGroup>();
foreach (var section in groupsSections) {
PermissionGroup group = new PermissionGroup();
group.Name = section.GetValue<string>("Name");
group.Permission = section.GetValue<string>("Permission");
group.Permissions = section.GetSection("Permissions").Get<string[]>();
group.Inherits = section.GetSection("Inherits").Get<string[]>();
groups.Add(group);
}
Groups = groups.ToArray();
}
private readonly DatabaseContext _context;
private readonly PermissionGroup[] _groups;
public GroupRepository(DatabaseContext context) {
_context = context;
_groups = Groups;
}
public PermissionGroup GetPermissionGroup(string name) {
return _groups.SingleOrDefault(group => group.Permission.Equals(name));
}
public PermissionGroup[] GetGroupsFromUser(Guid userId) {
Permission[] permissions = GetUserPermissionsRaw(userId).ToArray();
return ExtractGroups(permissions);
}
public PermissionGroup[] ExtractGroups(Permission[] permissions) {
List<PermissionGroup> permissionGroups = new List<PermissionGroup>();
foreach (var permission in permissions) {
if (permission.PermissionKey.StartsWith("group.")) {
foreach (var permissionGroup in _groups) {
if (permission.PermissionKey.Equals(permissionGroup.Permission)) {
permissionGroups.Add(permissionGroup);
if (permissionGroup.Inherits is not null) {
foreach (var inherit in permissionGroup.Inherits) {
permissionGroups.Add(GetPermissionGroup(inherit));
}
}
}
}
}
}
return permissionGroups.ToArray();
}
public IEnumerable<Permission> GetUserPermissions(Guid userId) {
List<Permission> permissions = GetUserPermissionsRaw(userId).ToList();
PermissionGroup[] groups = ExtractGroups(permissions.ToArray());
foreach (var group in groups) {
if (group.Permissions is null) continue;
permissions.AddRange(group.Permissions
.Select(perm => new Permission { Id = -1, UserId = userId, PermissionKey = perm }));
}
return permissions;
}
public IEnumerable<Permission> GetUserPermissionsRaw(Guid userId) {
return _context.Permissions.Where(permission => permission.UserId == userId);
}
public void AddPermissions(Guid userId, params string[] permissions) {
foreach (var permission in permissions) {
_context.Permissions.Add(new Permission
{ PermissionKey = permission, UserId = userId });
}
_context.SaveChanges();
}
public void DeletePermissions(Guid userId, params string[] permissions) {
foreach (var permission in permissions) {
_context.Permissions.RemoveRange(_context.Permissions.Where(perm =>
perm.UserId == userId && perm.PermissionKey == permission));
}
_context.SaveChanges();
}
}

View File

@@ -0,0 +1,92 @@
using System.Text;
using Backend.Entitys;
using Backend.Security.Authentication;
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
using Microsoft.Extensions.Options;
namespace Backend.Repositorys;
public class TokenRepository {
private readonly JwtTokenAuthenticationOptions _options;
private readonly DatabaseContext _context;
public TokenRepository(IOptions<JwtTokenAuthenticationOptions> options, DatabaseContext context) {
_options = options.Value;
_context = context;
}
public RefreshToken GetRefreshToken(Guid refreshTokenId) {
if (string.IsNullOrEmpty(refreshTokenId.ToString())) return null;
return _context.RefreshTokens.SingleOrDefault(token => token.Id == refreshTokenId);
}
public AccessToken GetAccessToken(Guid accessTokenId) {
if (string.IsNullOrEmpty(accessTokenId.ToString())) return null;
return _context.AccessTokens.SingleOrDefault(token => token.Id == accessTokenId);
}
public IEnumerable<AccessToken> GetAccessTokens(Guid refreshTokenId) {
if (string.IsNullOrEmpty(refreshTokenId.ToString())) return ArraySegment<AccessToken>.Empty;
return _context.AccessTokens.Where(token => token.RefreshTokenId == refreshTokenId);
}
public bool ValidateAccessToken(Guid accessTokenId) {
AccessToken token = GetAccessToken(accessTokenId);
if (token == null) return false;
TimeSpan span = token.ExpirationDate - DateTime.Now;
return span.TotalMilliseconds > 0;
}
public bool ValidateRefreshToken(Guid refreshTokenId) {
RefreshToken token = GetRefreshToken(refreshTokenId);
if (token == null) return false;
TimeSpan span = token.ExpirationDate - DateTime.Now;
return span.TotalMilliseconds > 0;
}
public RefreshToken CreateRefreshToken(Guid userId) {
RefreshToken token = new RefreshToken {
UserId = userId, Id = Guid.NewGuid(),
ExpirationDate = DateTime.Now.Add(new TimeSpan(int.Parse(_options.RefreshTokenExpirationTimeInHours), 0, 0))
};
_context.RefreshTokens.Add(token);
_context.SaveChanges();
return token;
}
public AccessToken CreateAccessToken(Guid refreshTokenId) {
AccessToken token = new AccessToken {
RefreshTokenId = refreshTokenId, Id = Guid.NewGuid(),
ExpirationDate = DateTime.Now
.Add(new TimeSpan(0, int.Parse(_options.AccessTokenExpirationTimeInMinutes), 0))
};
_context.AccessTokens.Add(token);
_context.SaveChanges();
return token;
}
public void DeleteUserTokens(Guid userId) {
List<RefreshToken> refreshTokens = _context.RefreshTokens.Where(token => token.UserId == userId).ToList();
refreshTokens.ForEach(token => DeleteRefreshToken(token.Id));
_context.SaveChanges();
}
public void DeleteRefreshToken(Guid refreshTokenId) {
_context.RefreshTokens.RemoveRange(_context.RefreshTokens.Where(token => token.Id == refreshTokenId));
_context.AccessTokens.RemoveRange(_context.AccessTokens.Where(token => token.RefreshTokenId == refreshTokenId));
}
public 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,68 @@
using Backend.Entitys;
using Backend.Options;
using Microsoft.Extensions.Options;
namespace Backend.Repositorys;
public class UserRepository {
private DatabaseContext _context;
private TokenRepository _tokens;
private GroupRepository _groups;
private UserOptions _options;
public UserRepository(DatabaseContext context, TokenRepository tokens, GroupRepository groups, IOptions<UserOptions> options) {
_context = context;
_tokens = tokens;
_groups = groups;
_options = options.Value;
}
public IEnumerable<User> GetUsers() => _context.Users.OrderBy(user => user.Created);
public User GetUser(Guid userId) => _context.Users.SingleOrDefault(user => user.Id == userId);
public User CreateUser(UserEditor editor) {
var user = new User {
Id = Guid.NewGuid(),
Created = DateTime.Now,
Email = editor.Email,
FirstName = editor.FirstName,
LastName = editor.LastName,
Password = TokenRepository.Hash128(editor.Password, editor.Email),
Username = editor.Username
};
_context.Users.Add(user);
_context.SaveChanges();
_groups.AddPermissions(user.Id, _options.DefaultPermissions);
return user;
}
public void EditUser(Guid userId, UserEditor editor) {
var user = GetUser(userId);
string SetValue(string orig, string input, string hashed = null) {
if (!string.IsNullOrEmpty(input))
return !string.IsNullOrEmpty(hashed) ? hashed : input;
return orig;
}
user.Email = SetValue(user.Email, editor.Email);
user.FirstName = SetValue(user.FirstName, editor.FirstName);
user.LastName = SetValue(user.LastName, editor.LastName);
user.Username = SetValue(user.Username, editor.Username);
user.Password = SetValue(user.Password, editor.Password, TokenRepository.Hash128(editor.Password, editor.Email));
_context.SaveChanges();
}
public void DeleteUser(Guid userId) {
_context.Users.Remove(_context.Users.Single(user => user.Id == userId));
_context.Permissions.RemoveRange(_context.Permissions.Where(perm => perm.UserId == userId));
_tokens.DeleteUserTokens(userId);
_context.SaveChanges();
}
}

View File

@@ -0,0 +1,5 @@
namespace Backend.Security.Authentication {
public static class JwtTokenAuthentication {
public const string Scheme = "JwtTokenAuthentication";
}
}

View File

@@ -0,0 +1,15 @@
using Backend.Options;
using Microsoft.AspNetCore.Authentication;
namespace Backend.Security.Authentication {
public static class JwtTokenAuthenticationExtensions {
public static AuthenticationBuilder AddJwtTokenAuthentication(this AuthenticationBuilder builder,
IConfiguration configuration) {
builder.Services.AddOptionsFromConfiguration<JwtTokenAuthenticationOptions>(configuration);
return builder.AddScheme<JwtTokenAuthenticationHandlerOptions, JwtTokenAuthenticationHandler>(
JwtTokenAuthentication.Scheme,
_ => { });
}
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Text.Encodings.Web;
using System.Security.Claims;
using Backend.Entitys;
using Backend.Repositorys;
using Backend.Security.Authorization;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
namespace Backend.Security.Authentication {
public class JwtTokenAuthenticationHandler : AuthenticationHandler<JwtTokenAuthenticationHandlerOptions> {
private readonly TokenRepository _tokens;
private readonly GroupRepository _groups;
private readonly JwtTokenAuthenticationOptions _options;
public JwtTokenAuthenticationHandler(
IOptionsMonitor<JwtTokenAuthenticationHandlerOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
IOptions<JwtTokenAuthenticationOptions> tokenOptions,
TokenRepository tokens,
GroupRepository groups)
: base(options, logger, encoder, clock) {
_options = tokenOptions.Value;
_tokens = tokens;
_groups = groups;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() {
if (Request.Headers["Authorization"].Equals(_options.DebugAccessToken))
return AuthenticateResult.Success(GetAuthenticationTicket(null, null, "*"));
var accessToken = GetAccessToken();
if (accessToken == null) return AuthenticateResult.Fail("Access Token invalid");
var refreshToken = _tokens.GetRefreshToken(accessToken.RefreshTokenId);
if (refreshToken == null) return AuthenticateResult.Fail("Refresh Token invalid");
if (!_tokens.ValidateRefreshToken(refreshToken.Id)) return AuthenticateResult.Fail("Refresh Token invalid");
bool valid = _tokens.ValidateAccessToken(accessToken.Id);
return valid
? AuthenticateResult.Success(GetAuthenticationTicket(accessToken, refreshToken))
: AuthenticateResult.Fail("Access Token invalid");
}
private AuthenticationTicket GetAuthenticationTicket(AccessToken accessToken, RefreshToken refreshToken, params string[] customPerms) {
List<Claim> claims = GenerateClaims(accessToken, refreshToken, customPerms);
ClaimsPrincipal principal = new ClaimsPrincipal();
principal.AddIdentity(new ClaimsIdentity(claims, JwtTokenAuthentication.Scheme));
AuthenticationTicket ticket = new AuthenticationTicket(principal, Scheme.Name);
return ticket;
}
private List<Claim> GenerateClaims(AccessToken accessToken, RefreshToken refreshToken, params string[] customPerms) {
List<Claim> claims = new List<Claim>();
if (accessToken is not null && refreshToken is not null) {
claims.AddRange(new List<Claim> {
new(CustomClaimTypes.AccessTokenId, accessToken.Id.ToString()),
new(CustomClaimTypes.RefreshTokenId, refreshToken.Id.ToString()),
new(CustomClaimTypes.UserId, refreshToken.UserId.ToString()),
});
string[] permissions = _groups.GetUserPermissions(refreshToken.UserId).Select(perm => perm.PermissionKey).ToArray();
claims.AddRange(permissions
.Select(permission => new Claim(CustomClaimTypes.Permission, permission)));
}
claims.AddRange(customPerms.Select(perm => new Claim(CustomClaimTypes.Permission, perm)));
return claims;
}
private AccessToken GetAccessToken() {
string key = Request.Headers["Authorization"];
if (string.IsNullOrEmpty(key)) {
key = Request.Query["token"];
}
if (string.IsNullOrEmpty(key))
return null;
AccessToken token = _tokens.GetAccessToken(Guid.Parse(key));
return token;
}
}
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Authentication;
namespace Backend.Security.Authentication {
public class JwtTokenAuthenticationHandlerOptions : AuthenticationSchemeOptions {
// Options for the authentication handler.
// Currently: None
}
}

View File

@@ -0,0 +1,11 @@
using Backend.Options;
namespace Backend.Security.Authentication {
public class JwtTokenAuthenticationOptions : OptionsFromConfiguration {
public override string Position => "Authentication";
public string RefreshTokenExpirationTimeInHours { get; set; }
public string AccessTokenExpirationTimeInMinutes { get; set; }
public string DebugAccessToken { get; set; }
}
}

View File

@@ -0,0 +1,5 @@
namespace Backend.Options {
public abstract class OptionsFromConfiguration {
public abstract string Position { get; }
}
}

View File

@@ -0,0 +1,17 @@
namespace Backend.Options {
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;
string position = optionsInstance.Position;
services.Configure((Action<T>)(options => {
IConfigurationSection section = configuration.GetSection(position);
if (section != null) {
section.Bind(options);
}
}));
return optionsInstance;
}
}
}

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Mvc;
namespace Backend.Security.Authorization {
public sealed class AuthorizedAttribute : TypeFilterAttribute {
public AuthorizedAttribute(params string[] permission) : base(typeof(AuthorizedFilter)) {
Arguments = new object[] { permission };
}
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Backend.Security.Authorization {
public class AuthorizedFilter : IAuthorizationFilter {
private readonly string[] _permissions;
public AuthorizedFilter(params string[] permissions) {
_permissions = permissions;
}
public void OnAuthorization(AuthorizationFilterContext context) {
if (EndpointHasAllowAnonymousFilter(context)) {
return;
}
if (!IsAuthenticated(context)) {
context.Result = new UnauthorizedResult();
return;
}
if (!ContainsRequiredRole(context)) {
context.Result = new ForbidResult();
return;
}
}
private static bool EndpointHasAllowAnonymousFilter(AuthorizationFilterContext context) {
return context.Filters.Any(item => item is IAllowAnonymousFilter);
}
private bool IsAuthenticated(AuthorizationFilterContext context) {
return context.HttpContext.User.Identity.IsAuthenticated;
}
private bool ContainsRequiredRole(AuthorizationFilterContext context) {
if (context.HttpContext.User.HasClaim(CustomClaimTypes.Permission, "*"))
return true;
var perms = context.HttpContext.User.Claims
.Where(c => c.Type == CustomClaimTypes.Permission)
.Select(c => c.Value).ToArray();
if (context.RouteData.Values.ContainsKey("userId")) {
var accessedUser = context.RouteData.Values["userId"] as string;
if (accessedUser == context.HttpContext.User.GetUserId()) {
var selfPerms = _permissions.Where(p => p.StartsWith("self.")).ToArray();
if (!selfPerms.Any())
return true;
if (CheckPermission(selfPerms, perms))
return true;
}
}
if (CheckPermission(_permissions, perms.Where(p => !p.StartsWith("self.")).ToArray()))
return true;
return false;
bool CheckPermission(string[] permissions, string[] permission) {
if (permissions.Length == 0)
return true;
if (permission.Contains("*"))
return true;
foreach (var perm in permissions) {
if (permission.Contains(perm))
return true;
string[] splice = perm.Split(".");
string cache = "";
foreach (var s in splice) {
cache += s + ".";
if (permission.Contains(cache + "*"))
return true;
}
}
return false;
}
}
}
}

View File

@@ -0,0 +1,20 @@
using System.Linq;
using System.Security.Claims;
namespace Backend.Security.Authorization {
public static class ClaimsPrincipalExtensions {
public static string GetAccessTokenId(this ClaimsPrincipal principal) =>
principal.FindFirstValue(CustomClaimTypes.AccessTokenId);
public static string GetRefreshTokenId(this ClaimsPrincipal principal) =>
principal.FindFirstValue(CustomClaimTypes.RefreshTokenId);
public static string GetUserId(this ClaimsPrincipal principal) =>
principal.FindFirstValue(CustomClaimTypes.UserId);
public static string[] GetPermissions(this ClaimsPrincipal principal) => principal.Claims
.Where(claim => claim.Type.Equals(CustomClaimTypes.Permission))
.Select(claim => claim.Value)
.ToArray();
}
}

View File

@@ -0,0 +1,8 @@
namespace Backend.Security.Authorization {
public static class CustomClaimTypes {
public const string AccessTokenId = "WebDesktop.AccessTokenId";
public const string RefreshTokenId = "WebDesktop.RefreshTokenId";
public const string UserId = "WebDesktop.UserId";
public const string Permission = "WebDesktop.Permission";
}
}

View File

@@ -0,0 +1,9 @@
namespace Backend.Security {
public interface ITokenContext {
bool IsAuthenticated { get; }
Guid UserId { get; }
Guid AccessTokenId { get; }
Guid RefreshTokenId { get; }
string[] Permissions { get; }
}
}

View File

@@ -0,0 +1,14 @@
namespace Backend.Security;
public static class Permissions {
public const string ShowUsers = "users.see";
public const string EditUsers = "users.edit";
public const string DeleteUsers = "users.delete";
public const string LogoutUsers = "users.logout";
public const string EditUserPermissions = "users.permissions.edit";
public const string ShowUserPermissions = "users.permissions.show";
public const string EditOwnPermissions = "self.permissions.edit";
}

View File

@@ -0,0 +1,26 @@
using Backend.Security.Authorization;
namespace Backend.Security {
internal class TokenContext : ITokenContext {
private readonly IHttpContextAccessor _accessor;
public TokenContext(IHttpContextAccessor accessor) {
_accessor = accessor;
}
public bool IsAuthenticated => _accessor.HttpContext?.User.Identity?.IsAuthenticated == true;
public Guid UserId => CreateGuild(_accessor.HttpContext?.User.GetUserId());
public Guid AccessTokenId => CreateGuild(_accessor.HttpContext?.User.GetAccessTokenId());
public Guid RefreshTokenId => CreateGuild(_accessor.HttpContext?.User.GetRefreshTokenId());
public string[] Permissions => _accessor.HttpContext?.User.GetPermissions();
private static Guid CreateGuild(string id) {
if (string.IsNullOrEmpty(id)) return Guid.Empty;
return Guid.Parse(id);
}
}
}

View File

@@ -0,0 +1,6 @@
{
"Origins": ["http://localhost:4200", "http://localhost:9876"],
"Authentication": {
"DebugAccessToken": "474a0461-37ec-4b11-aefe-00c423d1511e"
}
}

44
Backend/appsettings.json Normal file
View File

@@ -0,0 +1,44 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Backend.Security.Authentication.JwtTokenAuthenticationHandler": "None"
}
},
"MySQL": "SERVER=213.136.89.237;DATABASE=WebDesktop;UID=WebDesktop;PASSWORD=Hft6bP@V3IkYvqS1",
"Origins": ["https://desktop.leon-hoppe.de"],
"AllowedHosts": "*",
"Authentication": {
"RefreshTokenExpirationTimeInHours": 12,
"AccessTokenExpirationTimeInMinutes": 5,
"DebugAccessToken": null
},
"Users": {
"DefaultPermissions": ["group.user"]
},
"Groups": [
{
"Permission": "group.admin",
"Name": "Admin",
"Inherits": [],
"Permissions": ["*"]
},
{
"Permission": "group.user",
"Name": "User",
"Inherits": [],
"Permissions": []
}
],
"Messages": {
"Users": {
"NotFound": "This user does not exist",
"InvalidEditData": "Userdata does not match security rules",
"InvalidRegisterData": "Userdata does not match security rules",
"WrongPassword": "Wrong password",
"UsernameOrEmailExist": "This username or email already exist",
"InvalidRefreshToken": "Invalid RefreshToken"
}
}
}

16
Frontend/.browserslistrc Normal file
View File

@@ -0,0 +1,16 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
#last 1 Chrome version
#last 1 Firefox version
#last 2 Edge major versions
#last 2 Safari major versions
#last 2 iOS major versions
#Firefox ESR

7
Frontend/.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.editorconfig
/node_modules
/e2e
/docs
.gitignore
*.zip
*.md

16
Frontend/.editorconfig Normal file
View File

@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

46
Frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.angular/cache
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db

11
Frontend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
#stage 1
FROM node:latest as node
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build --prod
#stage 2
FROM nginx:alpine
COPY nginx.conf /etc/nginx/sites-available/default
COPY --from=node /app/dist/frontend /usr/share/nginx/html

27
Frontend/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Frontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 13.1.2.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

111
Frontend/angular.json Normal file
View File

@@ -0,0 +1,111 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"Frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
},
"@schematics/angular:application": {
"strict": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/frontend",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "Frontend:build:production"
},
"development": {
"browserTarget": "Frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "Frontend:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
},
"defaultProject": "Frontend"
}

44
Frontend/karma.conf.js Normal file
View File

@@ -0,0 +1,44 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/frontend'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

13
Frontend/nginx.conf Normal file
View File

@@ -0,0 +1,13 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
root /usr/share/nginx/html;
index index.html index.htm index.nginx-debian.html;
server_name _;
location / {
try_files $uri $uri/ /index.html;
}
}

19966
Frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
Frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "~13.1.0",
"@angular/common": "~13.1.0",
"@angular/compiler": "~13.1.0",
"@angular/core": "~13.1.0",
"@angular/forms": "~13.1.0",
"@angular/platform-browser": "~13.1.0",
"@angular/platform-browser-dynamic": "~13.1.0",
"@angular/router": "~13.1.0",
"rxjs": "~7.4.0",
"tslib": "^2.3.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "~13.1.2",
"@angular/cli": "~13.1.2",
"@angular/compiler-cli": "~13.1.0",
"@types/jasmine": "~3.10.0",
"@types/node": "^12.11.1",
"jasmine-core": "~3.10.0",
"karma": "~6.3.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.1.0",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "~1.7.0",
"typescript": "~4.5.2"
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import {LoginComponent} from "./sites/login/login.component";
const routes: Routes = [
{path: "login", component: LoginComponent}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }

View File

@@ -0,0 +1 @@
<router-outlet *ngIf="loaded"></router-outlet>

View File

View File

@@ -0,0 +1,27 @@
import {Component, OnInit} from '@angular/core';
import {BackendService} from "./services/backend.service";
import {Router} from "@angular/router";
import {UserApi} from "./services/users.service";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
public loaded: boolean = false;
constructor(private backend: BackendService, private router: Router, private users: UserApi) {}
ngOnInit(): void {
setTimeout(async () => {
if (this.router.url == "/login" || this.router.url == "/register") {
this.loaded = true;
return;
}
if (await this.backend.requestToken()) this.loaded = true;
else await this.router.navigate(["login"]);
}, 0);
}
}

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import {HttpClientModule} from "@angular/common/http";
import { LoginComponent } from './sites/login/login.component';
@NgModule({
declarations: [
AppComponent,
LoginComponent
],
imports: [
BrowserModule,
AppRoutingModule,
HttpClientModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

@@ -0,0 +1,5 @@
export interface AccessToken {
id: string;
refreshTokenId: string;
expirationDate: string;
}

View File

@@ -0,0 +1,17 @@
export interface User extends UserEditor {
id: string;
created: Date;
}
export interface UserLogin {
usernameOrEmail: string;
password: string;
}
export interface UserEditor {
firstName: string;
lastName: string;
email: string;
username: string;
password: string;
}

View File

@@ -0,0 +1,99 @@
import { Injectable } from '@angular/core';
import {HttpClient, HttpErrorResponse, HttpHeaders} from "@angular/common/http";
import {firstValueFrom} from "rxjs";
import {environment} from "../../environments/environment";
export interface BackendResponse<T> {
content: T;
success: boolean;
code: number;
message?: string;
}
export enum RequestTypes {
GET = 0,
PUT = 1,
POST = 2,
DELETE = 3
}
export interface RequestOptions {
withCredentials?: boolean;
authorized?: boolean;
}
@Injectable({
providedIn: 'root'
})
export class BackendService {
public authKey: string;
public headers: HttpHeaders = new HttpHeaders({
'Content-Type': 'application/json',
'Authorization': ''
});
constructor(private client: HttpClient) {}
public setToken(token: string): void {
this.authKey = token;
this.headers = this.headers.set("Authorization", token);
}
public async sendRequest<T>(type: RequestTypes, endpoint: string, body?: any, options?: RequestOptions): Promise<BackendResponse<T>> {
try {
let response;
switch (type) {
default:
case RequestTypes.GET:
response = await firstValueFrom(this.client.get<T>(environment.backendUrl + endpoint, {withCredentials: options?.withCredentials, headers: this.headers}));
break;
case RequestTypes.DELETE:
response = await firstValueFrom(this.client.delete<T>(environment.backendUrl + endpoint, {withCredentials: options?.withCredentials, headers: this.headers}));
break;
case RequestTypes.PUT:
response = await firstValueFrom(this.client.put<T>(environment.backendUrl + endpoint, body, {withCredentials: options?.withCredentials, headers: this.headers}));
break;
case RequestTypes.POST:
response = await firstValueFrom(this.client.post<T>(environment.backendUrl + endpoint, body, {withCredentials: options?.withCredentials, headers: this.headers}));
break;
}
return {content: response, success: true, code: 200};
} catch (e) {
const error = e as HttpErrorResponse;
if (error.status == 401 && options?.authorized) {
if (await this.requestToken()) {
options.authorized = false; // Prevent infinite resent loop
return this.sendRequest<T>(type, endpoint, body, options);
}
}
return {content: undefined, success: false, code: error.status, message: error.error.title};
}
}
public async testConnection(): Promise<boolean> {
try {
await this.client.get(environment.backendUrl);
return true;
}catch {
return false;
}
}
public async requestToken(): Promise<boolean> {
try {
const token = await firstValueFrom(this.client.get<{id: string, refreshTokenId: string, expirationDate: string}>(environment.backendUrl + "users/token", {headers: this.headers, withCredentials: true}));
this.setToken(token.id);
return true;
}catch {
return false;
}
}
}

View File

@@ -0,0 +1,88 @@
import {Injectable} from '@angular/core';
import {BackendService, RequestTypes} from "./backend.service";
import {User, UserEditor, UserLogin} from "../entitys/user";
import {AccessToken} from "../entitys/accessToken";
@Injectable({
providedIn: 'root'
})
export class UserApi {
private user: User;
constructor(private backend: BackendService) { }
public async getUsers(): Promise<User[]> {
const response = await this.backend.sendRequest<User[]>(RequestTypes.GET, "users", undefined, {authorized: true});
if (!response.success) return [];
return response.content;
}
public async getUser(id: string): Promise<User> {
const response = await this.backend.sendRequest<User>(RequestTypes.GET, "users/" + id, undefined, {authorized: true});
return response.content;
}
public async editUser(id: string, editor: UserEditor): Promise<boolean> {
const response = await this.backend.sendRequest<any>(RequestTypes.PUT, "users/" + id, editor, {authorized: true});
return response.success;
}
public async deleteUser(id: string): Promise<boolean> {
const response = await this.backend.sendRequest(RequestTypes.DELETE, "users/" + id, undefined, {authorized: true});
return response.success;
}
public async getUserPermissions(id: string, includeGroupPermissions: boolean = true): Promise<string[]> {
const response = await this.backend.sendRequest<string[]>(RequestTypes.GET, "users/" + id + "/permissions" + (includeGroupPermissions ? "/raw" : ""), undefined, {authorized: true});
if (!response.success) return [];
return response.content;
}
public async addUserPermissions(id: string, permissions: string[]): Promise<boolean> {
const response = await this.backend.sendRequest<any>(RequestTypes.POST, "users/" + id + "/permissions", permissions, {authorized: true});
return response.success;
}
public async removeUserPermissions(id: string, permissions: string[]): Promise<boolean> {
const response = await this.backend.sendRequest<any>(RequestTypes.PUT, "users/" + id + "/permissions", permissions, {authorized: true});
return response.success;
}
public async login(login: UserLogin): Promise<{success: boolean, errorMessage: string}> {
const response = await this.backend.sendRequest<AccessToken>(RequestTypes.PUT, "users/login", login, {withCredentials: true});
if (response.success) {
this.backend.setToken(response.content.id);
await this.getAuthorizedUser();
}
return {success: response.success, errorMessage: response.message};
}
public async register(register: UserEditor): Promise<{success: boolean, errorMessage: string}> {
const response = await this.backend.sendRequest<AccessToken>(RequestTypes.POST, "users/register", register, {withCredentials: true});
if (response.success) {
this.backend.setToken(response.content.id);
await this.getAuthorizedUser();
}
return {success: response.success, errorMessage: response.message};
}
public async logout(id: string): Promise<boolean> {
const response = await this.backend.sendRequest(RequestTypes.DELETE, "users/" + id + "/logout", undefined, {authorized: true, withCredentials: true});
return response.success;
}
public async getAuthorizedUser(): Promise<User> {
if (this.user != undefined) return this.user;
const response = await this.backend.sendRequest<User>(RequestTypes.GET, "users/self", undefined, {authorized: true});
if (response.success)
this.user = response.content;
return response.content;
}
}

View File

@@ -0,0 +1 @@
<p>login works!</p>

View File

@@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View File

View File

@@ -0,0 +1,4 @@
export const environment = {
production: true,
backendUrl: 'https://api.desktop.leon-hoppe.de/'
};

View File

@@ -0,0 +1,17 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false,
backendUrl: 'http://localhost:5142/'
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.

BIN
Frontend/src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

13
Frontend/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>WebDesktop</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

12
Frontend/src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));

53
Frontend/src/polyfills.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes recent versions of Safari, Chrome (including
* Opera), Edge on the desktop, and iOS and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

1
Frontend/src/styles.scss Normal file
View File

@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */

26
Frontend/src/test.ts Normal file
View File

@@ -0,0 +1,26 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
declare const require: {
context(path: string, deep?: boolean, filter?: RegExp): {
<T>(id: string): T;
keys(): string[];
};
};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
);
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);

View File

@@ -0,0 +1,19 @@
import {BackendService, RequestTypes} from "../app/services/backend.service";
import {TestBed} from "@angular/core/testing";
import {HttpClientModule} from "@angular/common/http";
describe('BackendService', () => {
let service : BackendService;
beforeEach(() => {
TestBed.configureTestingModule({imports: [HttpClientModule]})
service = TestBed.inject(BackendService);
})
it('should connect to the backend', function (done) {
service.testConnection().then(result => {
expect(result).toBeTrue();
done();
})
});
})

View File

@@ -0,0 +1,115 @@
import { TestBed } from '@angular/core/testing';
import {UserApi} from "../app/services/users.service";
import {BackendService} from "../app/services/backend.service";
import {HttpClientModule} from "@angular/common/http";
describe('UserApi', () => {
let backend: BackendService;
let service: UserApi;
let userId: string;
beforeAll((done) => {
TestBed.configureTestingModule({imports: [HttpClientModule]});
backend = TestBed.inject(BackendService);
service = TestBed.inject(UserApi);
service.register({
username: "tester",
password: "password",
email: "test@test.com",
firstName: "test",
lastName: "test"
}).then(success => {
expect(success.success).toBeTrue();
service.getAuthorizedUser().then(user => {
expect(user).not.toBeUndefined();
userId = user.id;
done();
})
})
}, 5000)
beforeEach(() => {
backend.setToken("474a0461-37ec-4b11-aefe-00c423d1511e");
});
it('should be create the service', () => {
expect(service).toBeTruthy();
});
it('should show all users', (done) => {
service.getUsers().then(users => {
expect(users).not.toBeUndefined();
done();
})
});
it('should login with the given credentials', (done) => {
service.login({
usernameOrEmail: "tester",
password: "password"
}).then(result => {
expect(result.success).toBeTrue();
done();
})
});
it('should delete all user sessions', (done) => {
service.login({
usernameOrEmail: "tester",
password: "password"
}).then(result => {
expect(result.success).toBeTrue();
service.logout(userId).then(success => {
expect(success).toBeTrue();
done();
})
})
});
it('should show the specified user', (done) => {
service.getUser(userId).then(user => {
expect(user).not.toBeUndefined();
done();
})
});
it('should edit the specified user', (done) => {
service.editUser(userId, {
username: "",
password: "",
email: "",
firstName: "Test",
lastName: "Test"
}).then(result => {
expect(result).toBeTrue();
done();
})
});
it('should show the permissions of the specified user', (done) => {
service.getUserPermissions(userId).then(perms => {
expect(perms).not.toBeUndefined();
done();
})
});
it('should add the permissions to the specified user', (done) => {
service.addUserPermissions(userId, ["*"]).then(success => {
expect(success).toBeTrue();
done();
})
});
it('should remove the permissions to the specified user', (done) => {
service.removeUserPermissions(userId, ["*"]).then(success => {
expect(success).toBeTrue();
done();
})
});
afterAll((done) => {
service.deleteUser(userId).then(() => done());
})
});

View File

@@ -0,0 +1,15 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.d.ts"
]
}

34
Frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "es2017",
"module": "es2020",
"lib": [
"es2020",
"dom"
],
"strictPropertyInitialization": false,
"strictNullChecks": false
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true,
}
}

View File

@@ -0,0 +1,18 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"files": [
"src/test.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

16
WebDesktop 2.0.sln Normal file
View File

@@ -0,0 +1,16 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend", "Backend\Backend.csproj", "{60AC6652-C470-460E-A069-7FAC54DA7587}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{60AC6652-C470-460E-A069-7FAC54DA7587}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{60AC6652-C470-460E-A069-7FAC54DA7587}.Debug|Any CPU.Build.0 = Debug|Any CPU
{60AC6652-C470-460E-A069-7FAC54DA7587}.Release|Any CPU.ActiveCfg = Release|Any CPU
{60AC6652-C470-460E-A069-7FAC54DA7587}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
version: "3.9"
services:
backend:
build: ./Backend
restart: unless-stopped
ports:
- "4122:5142"
frontend:
build: ./Frontend
restart: unless-stopped
ports:
- '4121:80'