aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--README.md60
-rw-r--r--VPNAuth.Server/Api/OAuth2.cs112
-rw-r--r--VPNAuth.Server/Api/Oidc.cs99
-rw-r--r--VPNAuth.Server/Api/UserInterface.cs45
-rw-r--r--VPNAuth.Server/Config.cs7
-rw-r--r--VPNAuth.Server/Database/AccessToken.cs1
-rw-r--r--VPNAuth.Server/Database/AuthRequest.cs1
-rw-r--r--VPNAuth.Server/Database/GarbageCollector.cs52
-rw-r--r--VPNAuth.Server/Migrations/20250419123149_AddUsernameFields.Designer.cs131
-rw-r--r--VPNAuth.Server/Migrations/20250419123149_AddUsernameFields.cs40
-rw-r--r--VPNAuth.Server/Migrations/DatabaseModelSnapshot.cs8
-rw-r--r--VPNAuth.Server/Pages/Auth.cshtml26
-rw-r--r--VPNAuth.Server/Pages/Auth.cshtml.cs10
-rw-r--r--VPNAuth.Server/Pages/Settings.cshtml (renamed from VPNAuth.Server/Pages/Dashboard.cshtml)42
-rw-r--r--VPNAuth.Server/PkceUtils.cs19
-rw-r--r--VPNAuth.Server/Program.cs112
-rw-r--r--VPNAuth.Server/Responses/OidcDiscovery.cs27
-rw-r--r--VPNAuth.Server/Responses/Token.cs2
-rw-r--r--VPNAuth.Server/VPNAuth.Server.csproj137
-rw-r--r--VPNAuth.Server/wwwroot/chota.css3
-rw-r--r--VPNAuth.Server/wwwroot/style.css25
-rw-r--r--deps.nix49
-rw-r--r--flake.lock61
-rw-r--r--flake.nix29
25 files changed, 894 insertions, 208 deletions
diff --git a/.gitignore b/.gitignore
index b2c8e23..effe9e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -488,3 +488,7 @@ VPNAuth.Server/vpnauth.db
VPNAuth.Server/vpnauth.db-shm
VPNAuth.Server/vpnauth.db-wal
VPNAuth.Server/config.json
+
+# nix
+out/
+result
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3244516
--- /dev/null
+++ b/README.md
@@ -0,0 +1,60 @@
+# VPNAuth
+
+OAuth2/OIDC Server recognizing users based on their IP.
+
+## The idea
+
+The idea of VPNAuth is that you only need to log into your VPN and from then you get automatically logged into your apps
+as you get recognized with your static ip you get assigned from your VPN.
+
+## Set-up
+
+I recommend to use the ``flake.nix`` to install the project on NixOS.
+VPNAuth will automatically generate the sqlite database and the ``config.json`` in the directory where the process runs
+from.
+
+### The Database
+
+You need to apply the ef core migrations to create and update the database as VPNAuth needs it.
+The recommended way to do that is described
+[here](https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/applying?tabs=dotnet-core-cli#sql-scripts).
+
+### The Config
+
+Here is an example ``config.json`` with explanations:
+
+``` JSON
+{
+ "Users": [ // A list with the user objects.
+ {
+ "Username": "tim", // The username of the user.
+ "Ips": [ // A list with the ips of the user as strings, the server uses them to recognize the user.
+ "127.0.0.1"
+ ]
+ }
+ ],
+ "Apps": [ // A list with the app objects that the users can log into.
+ {
+ "ClientId": "test-app", // The client id of the app used in the OAuth2 flow.
+ "RedirectUri": "http://127.0.0.1:8082/api/oauth2-redirect", // The user gets redirected to this uri when they accept or deny the login request.
+ "Secret": "mysecret", // The app secret used in the OAuth2 flow.
+ "AllowedUsers": ["tim"] // This key is OPTIONAL - when providden, only the users with their username as a string in that list can log into the app.
+ }
+ ]
+}
+```
+
+(Remember that this config is invalid as the JSON standard does not allow comments.)
+
+## Endpoints
+
+| Uri | Protocol | Description | Response status codes and their meaning |
+|-------------------|----------|----------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| ``/auth`` | OAuth | Autorization request - initializes the authorization process | / |
+| ``/access-token`` | OAuth | Endpoint where client requests the access token with the issued code in PKCE challenge | - ``400``: The form does not require required parameters<br/>- ``403``: client secret or code challenge is wrong<br/> - ``404``: invalid auth code |
+| ``/user-info`` | OIDC | Endpoint where the client requests information about the user | - ``405``: Request method is not ``GET`` or ``POST``<br/> - ``400``/``401``: invalid authorization header<br/> - ``403``: invalid token or not all required scopes<br/> - ``204``: User has not set any user information yet |
+
+You find out how to reach me [here](https://bytim.eu/contact) if you have any questions or feedback.
+
+**The [OAuth2](https://datatracker.ietf.org/doc/html/rfc6749), [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
+and [OIDC](https://openid.net/specs/openid-connect-core-1_0.html) Protocols are not fully implemented!**
diff --git a/VPNAuth.Server/Api/OAuth2.cs b/VPNAuth.Server/Api/OAuth2.cs
new file mode 100644
index 0000000..b7bdcf7
--- /dev/null
+++ b/VPNAuth.Server/Api/OAuth2.cs
@@ -0,0 +1,112 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+using VPNAuth.Server.Database;
+using VPNAuth.Server.Responses;
+
+namespace VPNAuth.Server.Api;
+
+public static class OAuth2
+{
+ public static async Task AcceptAuthHandler(HttpContext context, int id)
+ {
+ using var db = new Database.Database();
+ var authRequest = db.AuthRequests.Find(id);
+ if (authRequest == null || authRequest.Accepted)
+ {
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ return;
+ }
+
+ if (authRequest.Username != context.GetUser()?.Username)
+ {
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return;
+ }
+
+ authRequest.Accepted = true;
+ db.SaveChanges();
+
+ var config = Config.Read();
+ context.Response.StatusCode = StatusCodes.Status302Found;
+ context.Response.Headers["Location"] = config.FindApp(authRequest.ClientId)!.RedirectUri!
+ + "?code=" + authRequest.Code
+ + "&state=" + authRequest.State;
+ }
+
+ // -> https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
+ private static string HashCodeVerifier(string codeVerifier)
+ {
+ using var sha256 = SHA256.Create();
+ var removeCodeChallengeEnd = new Regex("=$");
+
+ var verifierBytes = Encoding.ASCII.GetBytes(codeVerifier);
+ var hashedVerifierBytes = sha256.ComputeHash(verifierBytes);
+ return removeCodeChallengeEnd.Replace(Convert.ToBase64String(hashedVerifierBytes), "")
+ .Replace("+", "-")
+ .Replace("/", "_");
+ }
+
+ // -> Request: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
+ // -> Response: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4
+ public static async Task AccessTokenHandler(HttpContext context)
+ {
+ var config = Config.Read();
+ if (context.Request.Form["grant_type"] != "authorization_code")
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ var clientSecret = config.FindApp(context.Request.Form["client_id"]!)!.Secret; // FIXME: null pointer
+ if (clientSecret != null && clientSecret != context.Request.Form["client_secret"])
+ {
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return;
+ }
+
+ using var db = new Database.Database();
+ var authRequest = db.AuthRequests
+ .Where(request => request.Code == context.Request.Form["code"].ToString())
+ .ToList()
+ .FirstOrDefault();
+ if (authRequest == null)
+ {
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ return;
+ }
+
+ if (!context.Request.Form.ContainsKey("code_verifier"))
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ var expectedCodeChallenge = HashCodeVerifier(context.Request.Form["code_verifier"].ToString());
+
+ if (expectedCodeChallenge != authRequest.CodeChallenge)
+ {
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return;
+ }
+
+ var accessTokenEntry = db.AccessTokens.Add(new AccessToken
+ {
+ ClientId = authRequest.ClientId,
+ Scopes = authRequest.Scopes,
+ CreationTime = DateTime.Now,
+ Token = PkceUtils.GenerateToken(),
+ Username = authRequest.Username
+ });
+
+ db.AuthRequests.Remove(authRequest);
+ db.SaveChanges();
+
+ await context.Response.WriteAsJsonAsync(new Token
+ {
+ AccessToken = accessTokenEntry.Entity.Token,
+ TokenType = "Bearer",
+ ExpiresIn = 604800 // 7 days
+ });
+ }
+}
diff --git a/VPNAuth.Server/Api/Oidc.cs b/VPNAuth.Server/Api/Oidc.cs
new file mode 100644
index 0000000..6c15113
--- /dev/null
+++ b/VPNAuth.Server/Api/Oidc.cs
@@ -0,0 +1,99 @@
+using VPNAuth.Server.Responses;
+
+namespace VPNAuth.Server.Api;
+
+public static class Oidc
+{
+ public static async Task UserInfoHandler(HttpContext context)
+ {
+ if (context.Request.Method != "GET" && context.Request.Method != "POST")
+ {
+ context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
+ return;
+ }
+
+ var tokenHeader = context.Request.Headers["Authorization"].First()?.Split(" ");
+
+ if (tokenHeader?.Length == 1 || tokenHeader?[0] != "Bearer")
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ if (tokenHeader.Length < 2)
+ {
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return;
+ }
+
+ using var db = new Database.Database();
+ var tokenDbEntry = db.AccessTokens
+ .Where(tokenEntry => tokenEntry.Token == tokenHeader[1])
+ .ToList()
+ .FirstOrDefault();
+
+ if (tokenDbEntry == null)
+ {
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return;
+ }
+
+ if (!tokenDbEntry.Scopes.Contains("openid"))
+ {
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return;
+ }
+
+ var userInformation = db.UserInformation
+ .Where(entry => entry.Sub == tokenDbEntry.Username)
+ .ToList()
+ .FirstOrDefault();
+
+ if (userInformation == null)
+ {
+ context.Response.StatusCode = StatusCodes.Status204NoContent;
+ return;
+ }
+
+ var userInfoResponse = new UserInfo();
+
+ if (tokenDbEntry.Scopes.Contains("profile"))
+ {
+ userInfoResponse.GivenName = userInformation.GivenName;
+ userInfoResponse.FamilyName = userInformation.FamilyName;
+ userInfoResponse.Name = userInformation.Name;
+ userInfoResponse.Picture = userInformation.Picture;
+ userInfoResponse.PreferredUsername = userInformation.PreferredUsername;
+ }
+
+ if (tokenDbEntry.Scopes.Contains("email"))
+ userInfoResponse.Email = userInformation.Email;
+
+ userInfoResponse.Sub = userInformation.Sub;
+
+ await context.Response.WriteAsJsonAsync(userInfoResponse);
+ }
+
+ public static async Task DiscoveryHandler(HttpContext context)
+ {
+ if (!context.Request.Host.HasValue)
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ var serverAddress = context.Request.IsHttps ? "https://" : "http://" + context.Request.Host.Value;
+
+ await context.Response.WriteAsJsonAsync(new OidcDiscovery
+ {
+ Issuer = serverAddress + "/",
+ AuthorizationEndpoint = $"{serverAddress}/auth",
+ TokenEndpoint = $"{serverAddress}/access-token",
+ UserInfoEndpoint = $"{serverAddress}/user-info",
+ JwksUri = "",
+ ResponseTypesSupported = ["code"],
+ SubjectTypesSupported = [],
+ IdTokenSigningAlgValuesSupported = ["RS256"]
+ });
+ }
+}
diff --git a/VPNAuth.Server/Api/UserInterface.cs b/VPNAuth.Server/Api/UserInterface.cs
new file mode 100644
index 0000000..274f9b1
--- /dev/null
+++ b/VPNAuth.Server/Api/UserInterface.cs
@@ -0,0 +1,45 @@
+using VPNAuth.Server.Database;
+
+namespace VPNAuth.Server.Api;
+
+public static class UserInterface
+{
+ public static async Task UserSettingsHandler(HttpContext context)
+ {
+ using var db = new Database.Database();
+
+ ConfigUser? configUser = context.GetUser();
+
+ if (configUser == null)
+ {
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ }
+
+ UserInformation? userInformation = db.UserInformation
+ .Where(user => user.Sub == configUser!.Username)
+ .ToList()
+ .FirstOrDefault() ?? db.Add(new UserInformation
+ {
+ Sub = configUser!.Username
+ }).Entity;
+
+ if (context.Request.Form.ContainsKey("given-name"))
+ userInformation.GivenName = context.Request.Form["given-name"]!;
+
+ if (context.Request.Form.ContainsKey("family-name"))
+ userInformation.FamilyName = context.Request.Form["family-name"]!;
+
+ if (context.Request.Form.ContainsKey("preferred-username"))
+ userInformation.PreferredUsername = context.Request.Form["preferred-username"]!;
+
+ if (context.Request.Form.ContainsKey("email"))
+ userInformation.Email = context.Request.Form["email"]!;
+
+ if (context.Request.Form.ContainsKey("picture"))
+ userInformation.Picture = context.Request.Form["picture"]!;
+
+ userInformation.Name = userInformation.GivenName + " " + userInformation.FamilyName;
+
+ db.SaveChanges();
+ }
+}
diff --git a/VPNAuth.Server/Config.cs b/VPNAuth.Server/Config.cs
index 32e72fa..cb57f11 100644
--- a/VPNAuth.Server/Config.cs
+++ b/VPNAuth.Server/Config.cs
@@ -13,6 +13,7 @@ public class ConfigApp
public string? ClientId { get; set; }
public string? RedirectUri { get; set; }
public string? Secret { get; set; }
+ public List<string>? AllowedUsers { get; set; }
}
public class Config
@@ -20,7 +21,7 @@ public class Config
public List<ConfigUser>? Users { get; set; }
public List<ConfigApp>? Apps { get; set; }
- public ConfigApp? FindApp(string clientId)
+ public ConfigApp? FindApp(string? clientId)
=> Apps?.Find(app => app.ClientId == clientId);
private static string _filePath = "./config.json";
@@ -29,10 +30,10 @@ public class Config
{
if (File.Exists(_filePath)) return;
- File.Create(_filePath);
File.WriteAllText(_filePath, JsonSerializer.Serialize(new Config
{
- Users = []
+ Users = [],
+ Apps = []
}));
}
diff --git a/VPNAuth.Server/Database/AccessToken.cs b/VPNAuth.Server/Database/AccessToken.cs
index 3cdc3ba..bb8fe7d 100644
--- a/VPNAuth.Server/Database/AccessToken.cs
+++ b/VPNAuth.Server/Database/AccessToken.cs
@@ -7,4 +7,5 @@ public class AccessToken
public string ClientId { get; set; }
public DateTime CreationTime { get; set; }
public List<string> Scopes { get; set; }
+ public string Username { get; set; }
}
diff --git a/VPNAuth.Server/Database/AuthRequest.cs b/VPNAuth.Server/Database/AuthRequest.cs
index 98fe001..11c05dc 100644
--- a/VPNAuth.Server/Database/AuthRequest.cs
+++ b/VPNAuth.Server/Database/AuthRequest.cs
@@ -11,4 +11,5 @@ public class AuthRequest
public string CodeChallenge { get; set; }
public string CodeChallengeMethod { get; set; }
public bool Accepted { get; set; }
+ public string Username { get; set; }
}
diff --git a/VPNAuth.Server/Database/GarbageCollector.cs b/VPNAuth.Server/Database/GarbageCollector.cs
new file mode 100644
index 0000000..c15f5a6
--- /dev/null
+++ b/VPNAuth.Server/Database/GarbageCollector.cs
@@ -0,0 +1,52 @@
+namespace VPNAuth.Server.Database;
+
+public static class GarbageCollector
+{
+ private static void CollectAuthRequests(Database db)
+ {
+ foreach (var authRequest in db.AuthRequests)
+ {
+ if ((DateTime.Now - authRequest.InitTime).TotalMinutes >= 5)
+ db.AuthRequests.Remove(authRequest);
+ }
+ }
+
+ private static void CollectTokens(Database db)
+ {
+ foreach (var accessToken in db.AccessTokens)
+ {
+ if ((DateTime.Now - accessToken.CreationTime).TotalDays >= 7)
+ db.AccessTokens.Remove(accessToken);
+ }
+ }
+
+ private static void CollectUserInformation(Database db)
+ {
+ Config config = Config.Read();
+ foreach (var dbUser in db.UserInformation)
+ {
+ if (config.Users!.All(configUser => configUser.Username != dbUser.Sub))
+ db.UserInformation.Remove(dbUser);
+ }
+ }
+
+ public static void StartLoop()
+ {
+ while (true)
+ {
+ using (var db = new Database())
+ {
+ CollectAuthRequests(db);
+ CollectTokens(db);
+ CollectUserInformation(db);
+
+ db.SaveChanges();
+ }
+
+ Task.Delay(60000).Wait(); // Wait 1 minute
+ }
+ }
+
+ public static void StartLoopAsync()
+ => new Task(StartLoop).Start();
+}
diff --git a/VPNAuth.Server/Migrations/20250419123149_AddUsernameFields.Designer.cs b/VPNAuth.Server/Migrations/20250419123149_AddUsernameFields.Designer.cs
new file mode 100644
index 0000000..8409c25
--- /dev/null
+++ b/VPNAuth.Server/Migrations/20250419123149_AddUsernameFields.Designer.cs
@@ -0,0 +1,131 @@
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using VPNAuth.Server.Database;
+
+#nullable disable
+
+namespace VPNAuth.Server.Migrations
+{
+ [DbContext(typeof(Database.Database))]
+ [Migration("20250419123149_AddUsernameFields")]
+ partial class AddUsernameFields
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
+
+ modelBuilder.Entity("VPNAuth.Server.Database.AccessToken", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ClientId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("CreationTime")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("Scopes")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Token")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("AccessTokens");
+ });
+
+ modelBuilder.Entity("VPNAuth.Server.Database.AuthRequest", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Accepted")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ClientId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Code")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodeChallenge")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodeChallengeMethod")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("InitTime")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("Scopes")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("State")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("AuthRequests");
+ });
+
+ modelBuilder.Entity("VPNAuth.Server.Database.UserInformation", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Email")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("FamilyName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("GivenName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Picture")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredUsername")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Sub")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("UserInformation");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/VPNAuth.Server/Migrations/20250419123149_AddUsernameFields.cs b/VPNAuth.Server/Migrations/20250419123149_AddUsernameFields.cs
new file mode 100644
index 0000000..3d649bb
--- /dev/null
+++ b/VPNAuth.Server/Migrations/20250419123149_AddUsernameFields.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace VPNAuth.Server.Migrations
+{
+ /// <inheritdoc />
+ public partial class AddUsernameFields : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<string>(
+ name: "Username",
+ table: "AuthRequests",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: "");
+
+ migrationBuilder.AddColumn<string>(
+ name: "Username",
+ table: "AccessTokens",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: "");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Username",
+ table: "AuthRequests");
+
+ migrationBuilder.DropColumn(
+ name: "Username",
+ table: "AccessTokens");
+ }
+ }
+}
diff --git a/VPNAuth.Server/Migrations/DatabaseModelSnapshot.cs b/VPNAuth.Server/Migrations/DatabaseModelSnapshot.cs
index e4643df..4dcce6b 100644
--- a/VPNAuth.Server/Migrations/DatabaseModelSnapshot.cs
+++ b/VPNAuth.Server/Migrations/DatabaseModelSnapshot.cs
@@ -38,6 +38,10 @@ namespace VPNAuth.Server.Migrations
.IsRequired()
.HasColumnType("TEXT");
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
b.HasKey("Id");
b.ToTable("AccessTokens");
@@ -78,6 +82,10 @@ namespace VPNAuth.Server.Migrations
b.Property<string>("State")
.HasColumnType("TEXT");
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
b.HasKey("Id");
b.ToTable("AuthRequests");
diff --git a/VPNAuth.Server/Pages/Auth.cshtml b/VPNAuth.Server/Pages/Auth.cshtml
index 5ac8efe..3b7c7c8 100644
--- a/VPNAuth.Server/Pages/Auth.cshtml
+++ b/VPNAuth.Server/Pages/Auth.cshtml
@@ -10,22 +10,38 @@
<html>
<head>
<title>VPNAuth - Auth</title>
+ <link rel="stylesheet" href="/static/style.css">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
-<body style="text-align: center;">
+<body>
<h1>Authorization</h1>
- <h2>VPNAuth</h2>
@if (Model.ValidRequest)
{
<div>
- <p>Do you want to log into <i>@Request.Query["client_id"]</i>?</p>
- <button onclick="window.location = '/accept-auth/@Model.RequestEntry?.Entity.Id'">Yes</button>
+ <p>Do you want to log into <i>@Request.Query["client_id"]</i> as user <i>@Model.User?.Username</i>?</p>
+ <p>Requested scopes:</p>
+ <ul>
+ @foreach (var scope in Model.RequestEntry!.Entity.Scopes)
+ {
+ <li>@scope</li>
+ }
+ </ul>
+ <button class="button primary"
+ onclick="window.location = '/accept-auth/@Model.RequestEntry?.Entity.Id'">Yes
+ </button>
+ <button class="button error"
+ onclick="window.location = '@(Model.Config.FindApp(Request.Query["client_id"]!)!.RedirectUri
+ + "?error=access_denied")'">No</button>
<br/>
- <p>You are logged in as <i>@Model.User?.Username</i>.</p>
</div>
}
else
{
<b>Invalid request.</b>
}
+
+ <footer>
+ <p style="margin-top: 5em;"><a target="_blank" href="https://bytim.eu/projects/VPNAuth/">VPNAuth</a> by Tim</p>
+ </footer>
</body>
</html>
diff --git a/VPNAuth.Server/Pages/Auth.cshtml.cs b/VPNAuth.Server/Pages/Auth.cshtml.cs
index bdcbc59..ea648cb 100644
--- a/VPNAuth.Server/Pages/Auth.cshtml.cs
+++ b/VPNAuth.Server/Pages/Auth.cshtml.cs
@@ -7,6 +7,7 @@ namespace VPNAuth.Server.Pages;
public class Auth : PageModel
{
public Config Config;
+ public ConfigApp? ConfApp;
public ConfigUser? User;
public bool ValidRequest;
public EntityEntry<AuthRequest>? RequestEntry;
@@ -23,12 +24,14 @@ public class Auth : PageModel
public void OnGet()
{
Config = Config.Read();
+ ConfApp = Config.FindApp(Request.Query["client_id"]);
User = HttpContext.GetUser();
ValidRequest = RequiredQueryParams.All(key => Request.Query.ContainsKey(key))
- && Config.FindApp(Request.Query["client_id"]!) != null
+ && ConfApp != null
&& Request.Query["code_challenge_method"] == "S256"
- && User != null;
+ && User != null
+ && (ConfApp.AllowedUsers == null || ConfApp.AllowedUsers!.Contains(User.Username!));
RequestEntry = null;
@@ -44,7 +47,8 @@ public class Auth : PageModel
Scopes = Request.Query["scope"].ToString().Split(" ").ToList(),
CodeChallenge = Request.Query["code_challenge"]!,
CodeChallengeMethod = Request.Query["code_challenge_method"]!,
- Accepted = false
+ Accepted = false,
+ Username = User!.Username!
});
db.SaveChanges();
}
diff --git a/VPNAuth.Server/Pages/Dashboard.cshtml b/VPNAuth.Server/Pages/Settings.cshtml
index 38f9c7e..c706e98 100644
--- a/VPNAuth.Server/Pages/Dashboard.cshtml
+++ b/VPNAuth.Server/Pages/Settings.cshtml
@@ -5,7 +5,6 @@
@{
Layout = null;
- string remoteIp = Request.HttpContext.GetRemoteIpAddress();
ConfigUser? configUser = Request.HttpContext.GetUser();
UserInformation? dbUser = null;
@@ -21,26 +20,25 @@
<html>
<head>
- <title>VPNAuth - Dashboard</title>
+ <title>VPNAuth - Settings</title>
+ <link rel="stylesheet" href="/static/style.css">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
-<body style="text-align: center;">
+<body>
@if (configUser == null)
{
- <p>No user detected</p>
+ <p>No user detected with IP <code>@Request.HttpContext.GetRemoteIpAddress()</code>.</p>
}
else
{
<div>
- <h1>Dashboard</h1>
- <h2>VPNAuth</h2>
- <p>Hey, @configUser.Username!</p>
- <h3>User settings</h3>
- <form hx-post="/user-info" hx-swap="none" hx-trigger="change">
- <table style="margin-left: auto; margin-right: auto;">
+ <h1>Settings</h1>
+ <form hx-post="/user-info-settings" hx-swap="none" hx-trigger="change">
+ <table>
<tbody>
<tr>
<th>Username</th>
- <th style="text-align: left; font-weight: normal;">@dbUser?.Sub</th>
+ <th class="normal">@dbUser?.Sub</th>
</tr>
<tr>
<th><label for="given-name">Given name</label></th>
@@ -67,18 +65,26 @@
<th><input name="picture" id="picture" type="url"
value="@dbUser?.Picture"/></th>
</tr>
+ <tr>
+ <th>IPs</th>
+ <th class="normal">
+ <ul>
+ @foreach (var ip in configUser.Ips!)
+ {
+ <li>@ip</li>
+ }
+ </ul>
+ </th>
+ </tr>
</tbody>
</table>
</form>
- <h3>Your IPs</h3>
- <ul style="list-style-position: inside;">
- @foreach (var ip in configUser.Ips!)
- {
- <li>@ip</li>
- }
- </ul>
</div>
}
+
+ <footer>
+ <p style="margin-top: 5em;"><a target="_blank" href="https://bytim.eu/projects/VPNAuth/">VPNAuth</a> by Tim</p>
+ </footer>
<script src="/static/htmx.js"></script>
</body>
</html>
diff --git a/VPNAuth.Server/PkceUtils.cs b/VPNAuth.Server/PkceUtils.cs
index a11926e..2299685 100644
--- a/VPNAuth.Server/PkceUtils.cs
+++ b/VPNAuth.Server/PkceUtils.cs
@@ -1,17 +1,24 @@
-namespace VPNAuth.Server;
+using System.Security.Cryptography;
+
+namespace VPNAuth.Server;
public static class PkceUtils
{
private static string _codeChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789";
+ private static string _tokenChars = _codeChars + ".!$";
- public static string GenerateCode(int length = 10)
+ private static string GenerateRandomString(string availableChars, int length)
{
- string code = "";
+ string randomString = "";
for (int i = 0; i < length; i++)
- code += _codeChars[new Random().Next(_codeChars.Length)]; // TODO: Is that function random enough?
- return code;
+ randomString +=
+ availableChars[RandomNumberGenerator.GetInt32(availableChars.Length)];
+ return randomString;
}
+ public static string GenerateCode(int length = 10)
+ => GenerateRandomString(_codeChars, length);
+
public static string GenerateToken(int length = 20)
- => GenerateCode(length); // TODO: maybe add more possible chars then for GenerateCode
+ => GenerateRandomString(_tokenChars, length);
}
diff --git a/VPNAuth.Server/Program.cs b/VPNAuth.Server/Program.cs
index 822aba7..5920807 100644
--- a/VPNAuth.Server/Program.cs
+++ b/VPNAuth.Server/Program.cs
@@ -1,8 +1,9 @@
using VPNAuth.Server;
+using VPNAuth.Server.Api;
using VPNAuth.Server.Database;
-using VPNAuth.Server.Responses;
Config.CreateIfNotExists();
+GarbageCollector.StartLoopAsync();
var builder = WebApplication.CreateBuilder(args);
@@ -24,110 +25,13 @@ app.UseStaticFiles(new StaticFileOptions
{
RequestPath = "/static"
});
-app.UseRouting();
-
-app.MapGet("/accept-auth/{id}", async (HttpContext context, int id) =>
-{
- using var db = new Database();
- var authRequest = db.AuthRequests.Find(id);
- if (authRequest == null || authRequest.Accepted)
- {
- context.Response.StatusCode = StatusCodes.Status404NotFound;
- return;
- }
-
- authRequest.Accepted = true;
- db.SaveChanges();
- var config = Config.Read();
- context.Response.StatusCode = StatusCodes.Status302Found;
- context.Response.Headers["Location"] = config.FindApp(authRequest.ClientId)!.RedirectUri!
- + "?code=" + authRequest.Code
- + "&state=" + authRequest.State;
-});
-
-app.MapPost("/access-token", async (HttpContext context) =>
-{
- var config = Config.Read();
- if (context.Request.Form["grant_type"] != "authorization_code")
- {
- context.Response.StatusCode = StatusCodes.Status400BadRequest;
- return;
- }
-
- var clientSecret = config.FindApp(context.Request.Form["client_id"]!)!.Secret; // FIXME: null pointer
- if (clientSecret != null && clientSecret != context.Request.Form["client_secret"])
- {
- context.Response.StatusCode = StatusCodes.Status403Forbidden;
- return;
- }
-
- using var db = new Database();
- var authRequest = db.AuthRequests
- .Where(request => request.Code == context.Request.Form["code"].ToString())
- .ToList()
- .FirstOrDefault();
- if (authRequest == null)
- {
- context.Response.StatusCode = StatusCodes.Status404NotFound;
- return;
- }
-
- // TODO: validate code verifier -> context.Request.Form["code_verifier"]
-
- var accessTokenEntry = db.AccessTokens.Add(new AccessToken
- {
- ClientId = authRequest.ClientId,
- Scopes = authRequest.Scopes,
- CreationTime = DateTime.Now,
- Token = PkceUtils.GenerateToken()
- });
- db.SaveChanges();
-
- await context.Response.WriteAsJsonAsync(new Token
- {
- AccessToken = accessTokenEntry.Entity.Token,
- TokenType = "Bearer",
- Expires = 0 // TODO: change to actual value
- });
-});
-
-app.MapPost("/user-info", async (HttpContext context) =>
-{
- using var db = new Database();
-
- ConfigUser? configUser = context.GetUser();
-
- if (configUser == null)
- {
- context.Response.StatusCode = StatusCodes.Status401Unauthorized;
- }
-
- UserInformation? userInformation = db.UserInformation.Where(user => user.Sub == configUser!.Username)
- .ToList().FirstOrDefault() ?? db.Add(new UserInformation
- {
- Sub = configUser!.Username
- }).Entity;
-
- if (context.Request.Form.ContainsKey("given-name"))
- userInformation.GivenName = context.Request.Form["given-name"]!;
-
- if (context.Request.Form.ContainsKey("family-name"))
- userInformation.FamilyName = context.Request.Form["family-name"]!;
-
- if (context.Request.Form.ContainsKey("preferred-username"))
- userInformation.PreferredUsername = context.Request.Form["preferred-username"]!;
-
- if (context.Request.Form.ContainsKey("email"))
- userInformation.Email = context.Request.Form["email"]!;
-
- if (context.Request.Form.ContainsKey("picture"))
- userInformation.Picture = context.Request.Form["picture"]!;
-
- userInformation.Name = userInformation.GivenName + " " + userInformation.FamilyName;
-
- db.SaveChanges();
-});
+app.UseRouting();
+app.MapGet("/accept-auth/{id}", OAuth2.AcceptAuthHandler);
+app.MapPost("/access-token", OAuth2.AccessTokenHandler);
+app.MapPost("/user-info-settings", UserInterface.UserSettingsHandler);
+app.Map("/user-info", Oidc.UserInfoHandler);
+app.MapGet("/.well-known/openid-configuration", Oidc.DiscoveryHandler);
app.MapStaticAssets();
app.MapRazorPages()
diff --git a/VPNAuth.Server/Responses/OidcDiscovery.cs b/VPNAuth.Server/Responses/OidcDiscovery.cs
new file mode 100644
index 0000000..e4d66f4
--- /dev/null
+++ b/VPNAuth.Server/Responses/OidcDiscovery.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace VPNAuth.Server.Responses;
+
+public class OidcDiscovery
+{
+ [JsonPropertyName("issuer")] public string Issuer { get; set; }
+
+ [JsonPropertyName("authorization_endpoint")]
+ public string AuthorizationEndpoint { get; set; }
+
+ [JsonPropertyName("token_endpoint")] public string TokenEndpoint { get; set; }
+
+ [JsonPropertyName("userinfo_endpoint")]
+ public string UserInfoEndpoint { get; set; }
+
+ [JsonPropertyName("jwks_uri")] public string JwksUri { get; set; }
+
+ [JsonPropertyName("response_types_supported")]
+ public List<string> ResponseTypesSupported { get; set; }
+
+ [JsonPropertyName("subject_types_supported")]
+ public List<string> SubjectTypesSupported { get; set; }
+
+ [JsonPropertyName("id_token_signing_alg_values_supported")]
+ public List<string> IdTokenSigningAlgValuesSupported { get; set; }
+}
diff --git a/VPNAuth.Server/Responses/Token.cs b/VPNAuth.Server/Responses/Token.cs
index 9d8a374..a4e9b2d 100644
--- a/VPNAuth.Server/Responses/Token.cs
+++ b/VPNAuth.Server/Responses/Token.cs
@@ -7,5 +7,5 @@ public class Token
[JsonPropertyName("access_token")] public string? AccessToken { get; set; }
[JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; }
[JsonPropertyName("token_type")] public string? TokenType { get; set; }
- [JsonPropertyName("expires")] public int? Expires { get; set; }
+ [JsonPropertyName("expires_in")] public int? ExpiresIn { get; set; }
}
diff --git a/VPNAuth.Server/VPNAuth.Server.csproj b/VPNAuth.Server/VPNAuth.Server.csproj
index 8bbec60..b793476 100644
--- a/VPNAuth.Server/VPNAuth.Server.csproj
+++ b/VPNAuth.Server/VPNAuth.Server.csproj
@@ -5,83 +5,84 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoDefaultLaunchSettingsFile>True</NoDefaultLaunchSettingsFile>
+ <Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
- <_ContentIncludedByDefault Remove="wwwroot\css\site.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.min.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.min.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.min.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.min.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.min.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.min.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.min.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.min.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.min.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.min.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.min.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.min.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.min.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.min.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.min.css" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.min.css.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.js.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.min.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.min.js.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.js.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.min.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.min.js.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.js.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.min.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.min.js.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\LICENSE" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation-unobtrusive\dist\jquery.validate.unobtrusive.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation-unobtrusive\dist\jquery.validate.unobtrusive.min.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation-unobtrusive\LICENSE.txt" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation\dist\additional-methods.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation\dist\additional-methods.min.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation\dist\jquery.validate.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation\dist\jquery.validate.min.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation\LICENSE.md" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.min.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.min.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.slim.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.slim.min.js" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.slim.min.map" />
- <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\LICENSE.txt" />
+ <_ContentIncludedByDefault Remove="wwwroot\css\site.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.min.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.min.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.min.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.min.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.min.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.min.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.min.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.min.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.min.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.min.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.min.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.min.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.min.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.min.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.min.css"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.min.css.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.js.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.min.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.bundle.min.js.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.js.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.min.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.esm.min.js.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.js.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.min.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\js\bootstrap.min.js.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\LICENSE"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation-unobtrusive\dist\jquery.validate.unobtrusive.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation-unobtrusive\dist\jquery.validate.unobtrusive.min.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation-unobtrusive\LICENSE.txt"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation\dist\additional-methods.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation\dist\additional-methods.min.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation\dist\jquery.validate.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation\dist\jquery.validate.min.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery-validation\LICENSE.md"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.min.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.min.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.slim.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.slim.min.js"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\dist\jquery.slim.min.map"/>
+ <_ContentIncludedByDefault Remove="wwwroot\lib\jquery\LICENSE.txt"/>
</ItemGroup>
<ItemGroup>
- <Folder Include="wwwroot\" />
+ <Folder Include="wwwroot\"/>
</ItemGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
- <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
- <PrivateAssets>all</PrivateAssets>
- </PackageReference>
- <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4"/>
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4"/>
</ItemGroup>
</Project>
diff --git a/VPNAuth.Server/wwwroot/chota.css b/VPNAuth.Server/wwwroot/chota.css
new file mode 100644
index 0000000..29aea8f
--- /dev/null
+++ b/VPNAuth.Server/wwwroot/chota.css
@@ -0,0 +1,3 @@
+/*!
+ * chota.css v0.9.2 | MIT License | https://github.com/jenil/chota
+ */:root{--bg-color:#fff;--bg-secondary-color:#f3f3f6;--color-primary:#14854f;--color-lightGrey:#d2d6dd;--color-grey:#747681;--color-darkGrey:#3f4144;--color-error:#d43939;--color-success:#28bd14;--grid-maxWidth:120rem;--grid-gutter:2rem;--font-size:1.6rem;--font-color:#333;--font-family-sans:-apple-system,"BlinkMacSystemFont","Avenir","Avenir Next","Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif;--font-family-mono:monaco,"Consolas","Lucida Console",monospace}html{-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;-ms-text-size-adjust:100%;text-size-adjust:100%;-webkit-box-sizing:border-box;box-sizing:border-box;font-size:62.5%;line-height:1.15}*,:after,:before{-webkit-box-sizing:inherit;box-sizing:inherit}body{background-color:var(--bg-color);color:var(--font-color);font-family:Segoe UI,Helvetica Neue,sans-serif;font-family:var(--font-family-sans);font-size:var(--font-size);line-height:1.6;margin:0;padding:0}h1,h2,h3,h4,h5,h6{font-weight:500;margin:.35em 0 .7em}h1{font-size:2em}h2{font-size:1.75em}h3{font-size:1.5em}h4{font-size:1.25em}h5{font-size:1em}h6{font-size:.85em}a{color:var(--color-primary);text-decoration:none}a:hover:not(.button){opacity:.75}button{font-family:inherit}p{margin-top:0}blockquote{background-color:var(--bg-secondary-color);border-left:3px solid var(--color-lightGrey);padding:1.5rem 2rem}dl dt{font-weight:700}hr{background-color:var(--color-lightGrey);height:1px;margin:1rem 0}hr,table{border:none}table{border-collapse:collapse;border-spacing:0;text-align:left;width:100%}table.striped tr:nth-of-type(2n){background-color:var(--bg-secondary-color)}td,th{padding:1.2rem .4rem;vertical-align:middle}thead{border-bottom:2px solid var(--color-lightGrey)}tfoot{border-top:2px solid var(--color-lightGrey)}code,kbd,pre,samp,tt{font-family:var(--font-family-mono)}code,kbd{border-radius:4px;color:var(--color-error);font-size:90%;padding:.2em .4em;white-space:pre-wrap}code,kbd,pre{background-color:var(--bg-secondary-color)}pre{font-size:1em;overflow-x:auto;padding:1rem}pre code{background:none;padding:0}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}img{max-width:100%}fieldset{border:1px solid var(--color-lightGrey)}iframe{border:0}.container{margin:0 auto;max-width:var(--grid-maxWidth);padding:0 calc(var(--grid-gutter)/2);width:96%}.row{-webkit-box-direction:normal;-webkit-box-pack:start;-ms-flex-pack:start;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;justify-content:flex-start;margin-left:calc(var(--grid-gutter)/-2);margin-right:calc(var(--grid-gutter)/-2)}.row,.row.reverse{-webkit-box-orient:horizontal}.row.reverse{-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.col{-webkit-box-flex:1;-ms-flex:1;flex:1}.col,[class*=" col-"],[class^=col-]{margin:0 calc(var(--grid-gutter)/2) calc(var(--grid-gutter)/2)}.col-1{-ms-flex:0 0 calc(8.33333% - var(--grid-gutter));flex:0 0 calc(8.33333% - var(--grid-gutter));max-width:calc(8.33333% - var(--grid-gutter))}.col-1,.col-2{-webkit-box-flex:0}.col-2{-ms-flex:0 0 calc(16.66667% - var(--grid-gutter));flex:0 0 calc(16.66667% - var(--grid-gutter));max-width:calc(16.66667% - var(--grid-gutter))}.col-3{-ms-flex:0 0 calc(25% - var(--grid-gutter));flex:0 0 calc(25% - var(--grid-gutter));max-width:calc(25% - var(--grid-gutter))}.col-3,.col-4{-webkit-box-flex:0}.col-4{-ms-flex:0 0 calc(33.33333% - var(--grid-gutter));flex:0 0 calc(33.33333% - var(--grid-gutter));max-width:calc(33.33333% - var(--grid-gutter))}.col-5{-ms-flex:0 0 calc(41.66667% - var(--grid-gutter));flex:0 0 calc(41.66667% - var(--grid-gutter));max-width:calc(41.66667% - var(--grid-gutter))}.col-5,.col-6{-webkit-box-flex:0}.col-6{-ms-flex:0 0 calc(50% - var(--grid-gutter));flex:0 0 calc(50% - var(--grid-gutter));max-width:calc(50% - var(--grid-gutter))}.col-7{-ms-flex:0 0 calc(58.33333% - var(--grid-gutter));flex:0 0 calc(58.33333% - var(--grid-gutter));max-width:calc(58.33333% - var(--grid-gutter))}.col-7,.col-8{-webkit-box-flex:0}.col-8{-ms-flex:0 0 calc(66.66667% - var(--grid-gutter));flex:0 0 calc(66.66667% - var(--grid-gutter));max-width:calc(66.66667% - var(--grid-gutter))}.col-9{-ms-flex:0 0 calc(75% - var(--grid-gutter));flex:0 0 calc(75% - var(--grid-gutter));max-width:calc(75% - var(--grid-gutter))}.col-10,.col-9{-webkit-box-flex:0}.col-10{-ms-flex:0 0 calc(83.33333% - var(--grid-gutter));flex:0 0 calc(83.33333% - var(--grid-gutter));max-width:calc(83.33333% - var(--grid-gutter))}.col-11{-ms-flex:0 0 calc(91.66667% - var(--grid-gutter));flex:0 0 calc(91.66667% - var(--grid-gutter));max-width:calc(91.66667% - var(--grid-gutter))}.col-11,.col-12{-webkit-box-flex:0}.col-12{-ms-flex:0 0 calc(100% - var(--grid-gutter));flex:0 0 calc(100% - var(--grid-gutter));max-width:calc(100% - var(--grid-gutter))}@media screen and (max-width:599px){.container{width:100%}.col,[class*=col-],[class^=col-]{-webkit-box-flex:0;-ms-flex:0 1 100%;flex:0 1 100%;max-width:100%}}@media screen and (min-width:900px){.col-1-md{-webkit-box-flex:0;-ms-flex:0 0 calc(8.33333% - var(--grid-gutter));flex:0 0 calc(8.33333% - var(--grid-gutter));max-width:calc(8.33333% - var(--grid-gutter))}.col-2-md{-webkit-box-flex:0;-ms-flex:0 0 calc(16.66667% - var(--grid-gutter));flex:0 0 calc(16.66667% - var(--grid-gutter));max-width:calc(16.66667% - var(--grid-gutter))}.col-3-md{-webkit-box-flex:0;-ms-flex:0 0 calc(25% - var(--grid-gutter));flex:0 0 calc(25% - var(--grid-gutter));max-width:calc(25% - var(--grid-gutter))}.col-4-md{-webkit-box-flex:0;-ms-flex:0 0 calc(33.33333% - var(--grid-gutter));flex:0 0 calc(33.33333% - var(--grid-gutter));max-width:calc(33.33333% - var(--grid-gutter))}.col-5-md{-webkit-box-flex:0;-ms-flex:0 0 calc(41.66667% - var(--grid-gutter));flex:0 0 calc(41.66667% - var(--grid-gutter));max-width:calc(41.66667% - var(--grid-gutter))}.col-6-md{-webkit-box-flex:0;-ms-flex:0 0 calc(50% - var(--grid-gutter));flex:0 0 calc(50% - var(--grid-gutter));max-width:calc(50% - var(--grid-gutter))}.col-7-md{-webkit-box-flex:0;-ms-flex:0 0 calc(58.33333% - var(--grid-gutter));flex:0 0 calc(58.33333% - var(--grid-gutter));max-width:calc(58.33333% - var(--grid-gutter))}.col-8-md{-webkit-box-flex:0;-ms-flex:0 0 calc(66.66667% - var(--grid-gutter));flex:0 0 calc(66.66667% - var(--grid-gutter));max-width:calc(66.66667% - var(--grid-gutter))}.col-9-md{-webkit-box-flex:0;-ms-flex:0 0 calc(75% - var(--grid-gutter));flex:0 0 calc(75% - var(--grid-gutter));max-width:calc(75% - var(--grid-gutter))}.col-10-md{-webkit-box-flex:0;-ms-flex:0 0 calc(83.33333% - var(--grid-gutter));flex:0 0 calc(83.33333% - var(--grid-gutter));max-width:calc(83.33333% - var(--grid-gutter))}.col-11-md{-webkit-box-flex:0;-ms-flex:0 0 calc(91.66667% - var(--grid-gutter));flex:0 0 calc(91.66667% - var(--grid-gutter));max-width:calc(91.66667% - var(--grid-gutter))}.col-12-md{-webkit-box-flex:0;-ms-flex:0 0 calc(100% - var(--grid-gutter));flex:0 0 calc(100% - var(--grid-gutter));max-width:calc(100% - var(--grid-gutter))}}@media screen and (min-width:1200px){.col-1-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(8.33333% - var(--grid-gutter));flex:0 0 calc(8.33333% - var(--grid-gutter));max-width:calc(8.33333% - var(--grid-gutter))}.col-2-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(16.66667% - var(--grid-gutter));flex:0 0 calc(16.66667% - var(--grid-gutter));max-width:calc(16.66667% - var(--grid-gutter))}.col-3-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(25% - var(--grid-gutter));flex:0 0 calc(25% - var(--grid-gutter));max-width:calc(25% - var(--grid-gutter))}.col-4-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(33.33333% - var(--grid-gutter));flex:0 0 calc(33.33333% - var(--grid-gutter));max-width:calc(33.33333% - var(--grid-gutter))}.col-5-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(41.66667% - var(--grid-gutter));flex:0 0 calc(41.66667% - var(--grid-gutter));max-width:calc(41.66667% - var(--grid-gutter))}.col-6-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(50% - var(--grid-gutter));flex:0 0 calc(50% - var(--grid-gutter));max-width:calc(50% - var(--grid-gutter))}.col-7-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(58.33333% - var(--grid-gutter));flex:0 0 calc(58.33333% - var(--grid-gutter));max-width:calc(58.33333% - var(--grid-gutter))}.col-8-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(66.66667% - var(--grid-gutter));flex:0 0 calc(66.66667% - var(--grid-gutter));max-width:calc(66.66667% - var(--grid-gutter))}.col-9-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(75% - var(--grid-gutter));flex:0 0 calc(75% - var(--grid-gutter));max-width:calc(75% - var(--grid-gutter))}.col-10-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(83.33333% - var(--grid-gutter));flex:0 0 calc(83.33333% - var(--grid-gutter));max-width:calc(83.33333% - var(--grid-gutter))}.col-11-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(91.66667% - var(--grid-gutter));flex:0 0 calc(91.66667% - var(--grid-gutter));max-width:calc(91.66667% - var(--grid-gutter))}.col-12-lg{-webkit-box-flex:0;-ms-flex:0 0 calc(100% - var(--grid-gutter));flex:0 0 calc(100% - var(--grid-gutter));max-width:calc(100% - var(--grid-gutter))}}fieldset{padding:.5rem 2rem}legend{font-size:.8em;letter-spacing:.1rem;text-transform:uppercase}input:not([type=checkbox],[type=radio],[type=submit],[type=color],[type=button],[type=reset]),select,textarea,textarea[type=text]{border:1px solid var(--color-lightGrey);border-radius:4px;display:block;font-family:inherit;font-size:1em;padding:.8rem 1rem;-webkit-transition:all .2s ease;transition:all .2s ease;width:100%}select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#f3f3f6 no-repeat 100%;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='60' height='40' fill='%23555'><polygon points='0,0 60,0 30,40'/></svg>");background-origin:content-box;background-size:1ex}.button,[type=button],[type=reset],[type=submit],button{background:var(--color-lightGrey);border:1px solid transparent;border-radius:4px;color:var(--color-darkGrey);cursor:pointer;display:inline-block;font-size:var(--font-size);line-height:1;padding:1rem 2.5rem;text-align:center;text-decoration:none;-webkit-transform:scale(1);transform:scale(1);-webkit-transition:opacity .2s ease;transition:opacity .2s ease}.button.dark,.button.error,.button.primary,.button.secondary,.button.success,[type=submit]{background-color:#000;background-color:var(--color-primary);color:#fff;z-index:1}.button:hover,[type=button]:hover,[type=reset]:hover,[type=submit]:hover,button:hover{opacity:.8}button:disabled,button:disabled:hover,input:disabled,input:disabled:hover{cursor:not-allowed;opacity:.4}.grouped{display:-webkit-box;display:-ms-flexbox;display:flex}.grouped>:not(:last-child){margin-right:16px}.grouped.gapless>*{border-radius:0!important;margin:0 0 0 -1px!important}.grouped.gapless>:first-child{border-radius:4px 0 0 4px!important;margin:0!important}.grouped.gapless>:last-child{border-radius:0 4px 4px 0!important}input:not([type=checkbox],[type=radio],[type=submit],[type=color],[type=button],[type=reset],:disabled):hover,select:hover,textarea:hover,textarea[type=text]:hover{border-color:var(--color-grey)}input:not([type=checkbox],[type=radio],[type=submit],[type=color],[type=button],[type=reset]):focus,select:focus,textarea:focus,textarea[type=text]:focus{border-color:var(--color-primary);-webkit-box-shadow:0 0 1px var(--color-primary);box-shadow:0 0 1px var(--color-primary);outline:none}input.error:not([type=checkbox],[type=radio],[type=submit],[type=color],[type=button],[type=reset]),textarea.error{border-color:var(--color-error)}input.success:not([type=checkbox],[type=radio],[type=submit],[type=color],[type=button],[type=reset]),textarea.success{border-color:var(--color-success)}[type=checkbox],[type=radio]{height:1.6rem;width:2rem}.button+.button{margin-left:1rem}.button.secondary{background-color:var(--color-grey)}.button.dark{background-color:var(--color-darkGrey)}.button.error{background-color:var(--color-error)}.button.success{background-color:var(--color-success)}.button.outline{background-color:transparent;border-color:var(--color-lightGrey)}.button.outline.primary{border-color:var(--color-primary);color:var(--color-primary)}.button.outline.secondary{border-color:var(--color-grey);color:var(--color-grey)}.button.outline.dark{border-color:var(--color-darkGrey);color:var(--color-darkGrey)}.button.clear{background-color:transparent;border-color:transparent;color:var(--color-primary)}.button.icon{-webkit-box-align:center;-ms-flex-align:center;align-items:center;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex}.button.icon>img{margin-left:2px}.button.icon-only{padding:1rem}.button:active:not(:disabled),[type=button]:active:not(:disabled),[type=reset]:active:not(:disabled),[type=submit]:active:not(:disabled),button:active:not(:disabled){-webkit-transform:scale(.98);transform:scale(.98)}::-webkit-input-placeholder{color:#bdbfc4}::-moz-placeholder{color:#bdbfc4}:-ms-input-placeholder{color:#bdbfc4}::-ms-input-placeholder{color:#bdbfc4}::placeholder{color:#bdbfc4}.nav{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;display:-webkit-box;display:-ms-flexbox;display:flex;min-height:5rem}.nav img{max-height:3rem}.nav-center,.nav-left,.nav-right,.nav>.container{display:-webkit-box;display:-ms-flexbox;display:flex}.nav-center,.nav-left,.nav-right{-webkit-box-flex:1;-ms-flex:1;flex:1}.nav-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.nav-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.nav-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}@media screen and (max-width:480px){.nav,.nav>.container{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.nav-center,.nav-left,.nav-right{-webkit-box-pack:center;-ms-flex-pack:center;-ms-flex-wrap:wrap;flex-wrap:wrap;justify-content:center}}.nav .brand,.nav a{-webkit-box-align:center;-ms-flex-align:center;align-items:center;color:var(--color-darkGrey);display:-webkit-box;display:-ms-flexbox;display:flex;padding:1rem 2rem;text-decoration:none}.nav .active:not(.button),.nav [aria-current=page]:not(.button){color:#000;color:var(--color-primary)}.nav .brand{font-size:1.75em;padding-bottom:0;padding-top:0}.nav .brand img{padding-right:1rem}.nav .button{margin:auto 1rem}.card{background:var(--bg-color);border-radius:4px;-webkit-box-shadow:0 1px 3px var(--color-grey);box-shadow:0 1px 3px var(--color-grey);padding:1rem 2rem}.card p:last-child{margin:0}.card header>*{margin-bottom:1rem;margin-top:0}.tabs{display:-webkit-box;display:-ms-flexbox;display:flex}.tabs a{text-decoration:none}.tabs>.dropdown>summary,.tabs>a{-webkit-box-flex:0;border-bottom:2px solid var(--color-lightGrey);color:var(--color-darkGrey);-ms-flex:0 1 auto;flex:0 1 auto;padding:1rem 2rem;text-align:center}.tabs>a.active,.tabs>a:hover,.tabs>a[aria-current=page]{border-bottom:2px solid var(--color-darkGrey);opacity:1}.tabs>a.active,.tabs>a[aria-current=page]{border-color:var(--color-primary)}.tabs.is-full a{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto}.tag{border:1px solid var(--color-lightGrey);color:var(--color-grey);display:inline-block;letter-spacing:.5px;line-height:1;padding:.5rem;text-transform:uppercase}.tag.is-small{font-size:.75em;padding:.4rem}.tag.is-large{font-size:1.125em;padding:.7rem}.tag+.tag{margin-left:1rem}details.dropdown{display:inline-block;position:relative}details.dropdown>:last-child{left:0;position:absolute;white-space:nowrap}.bg-primary{background-color:var(--color-primary)!important}.bg-light{background-color:var(--color-lightGrey)!important}.bg-dark{background-color:var(--color-darkGrey)!important}.bg-grey{background-color:var(--color-grey)!important}.bg-error{background-color:var(--color-error)!important}.bg-success{background-color:var(--color-success)!important}.bd-primary{border:1px solid var(--color-primary)!important}.bd-light{border:1px solid var(--color-lightGrey)!important}.bd-dark{border:1px solid var(--color-darkGrey)!important}.bd-grey{border:1px solid var(--color-grey)!important}.bd-error{border:1px solid var(--color-error)!important}.bd-success{border:1px solid var(--color-success)!important}.text-primary{color:var(--color-primary)!important}.text-light{color:var(--color-lightGrey)!important}.text-dark{color:var(--color-darkGrey)!important}.text-grey{color:var(--color-grey)!important}.text-error{color:var(--color-error)!important}.text-success{color:var(--color-success)!important}.text-white{color:#fff!important}.pull-right{float:right!important}.pull-left{float:left!important}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-justify{text-align:justify}.text-uppercase{text-transform:uppercase}.text-lowercase{text-transform:lowercase}.text-capitalize{text-transform:capitalize}.is-full-screen{min-height:100vh;width:100%}.is-full-width{width:100%!important}.is-vertical-align{-webkit-box-align:center;-ms-flex-align:center;align-items:center;display:-webkit-box;display:-ms-flexbox;display:flex}.is-center,.is-horizontal-align{-webkit-box-pack:center;-ms-flex-pack:center;display:-webkit-box;display:-ms-flexbox;display:flex;justify-content:center}.is-center{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.is-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.is-left,.is-right{-webkit-box-align:center;-ms-flex-align:center;align-items:center;display:-webkit-box;display:-ms-flexbox;display:flex}.is-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.is-fixed{position:fixed;width:100%}.is-paddingless{padding:0!important}.is-marginless{margin:0!important}.is-pointer{cursor:pointer!important}.is-rounded{border-radius:100%}.clearfix{clear:both;content:"";display:table}.is-hidden{display:none!important}@media screen and (max-width:599px){.hide-xs{display:none!important}}@media screen and (min-width:600px) and (max-width:899px){.hide-sm{display:none!important}}@media screen and (min-width:900px) and (max-width:1199px){.hide-md{display:none!important}}@media screen and (min-width:1200px){.hide-lg{display:none!important}}@media print{.hide-pr{display:none!important}} \ No newline at end of file
diff --git a/VPNAuth.Server/wwwroot/style.css b/VPNAuth.Server/wwwroot/style.css
new file mode 100644
index 0000000..c1dfc25
--- /dev/null
+++ b/VPNAuth.Server/wwwroot/style.css
@@ -0,0 +1,25 @@
+@import url("/static/chota.css");
+
+body {
+ text-align: center;
+}
+
+ul, li {
+ list-style-position: inside;
+}
+
+table {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+th.normal {
+ text-align: left;
+ font-weight: normal;
+}
+
+form {
+ width: fit-content;
+ margin-left: auto;
+ margin-right: auto;
+}
diff --git a/deps.nix b/deps.nix
new file mode 100644
index 0000000..8338f90
--- /dev/null
+++ b/deps.nix
@@ -0,0 +1,49 @@
+{ fetchNuGet }: [
+ (fetchNuGet { pname = "Humanizer.Core"; version = "2.14.1"; hash = "sha256-EXvojddPu+9JKgOG9NSQgUTfWq1RpOYw7adxDPKDJ6o="; })
+ (fetchNuGet { pname = "Microsoft.Bcl.AsyncInterfaces"; version = "7.0.0"; hash = "sha256-1e031E26iraIqun84ad0fCIR4MJZ1hcQo4yFN+B7UfE="; })
+ (fetchNuGet { pname = "Microsoft.Build.Framework"; version = "17.8.3"; hash = "sha256-Rp4dN8ejOXqclIKMUXYvIliM6IYB7WMckMLwdCbVZ34="; })
+ (fetchNuGet { pname = "Microsoft.Build.Locator"; version = "1.7.8"; hash = "sha256-VhZ4jiJi17Cd5AkENXL1tjG9dV/oGj0aY67IGYd7vNs="; })
+ (fetchNuGet { pname = "Microsoft.CodeAnalysis.Analyzers"; version = "3.3.4"; hash = "sha256-qDzTfZBSCvAUu9gzq2k+LOvh6/eRvJ9++VCNck/ZpnE="; })
+ (fetchNuGet { pname = "Microsoft.CodeAnalysis.Common"; version = "4.8.0"; hash = "sha256-3IEinVTZq6/aajMVA8XTRO3LTIEt0PuhGyITGJLtqz4="; })
+ (fetchNuGet { pname = "Microsoft.CodeAnalysis.CSharp"; version = "4.8.0"; hash = "sha256-MmOnXJvd/ezs5UPcqyGLnbZz5m+VedpRfB+kFZeeqkU="; })
+ (fetchNuGet { pname = "Microsoft.CodeAnalysis.CSharp.Workspaces"; version = "4.8.0"; hash = "sha256-WNzc+6mKqzPviOI0WMdhKyrWs8u32bfGj2XwmfL7bwE="; })
+ (fetchNuGet { pname = "Microsoft.CodeAnalysis.Workspaces.Common"; version = "4.8.0"; hash = "sha256-X8R4SpWVO/gpip5erVZf5jCCx8EX3VzIRtNrQiLDIoM="; })
+ (fetchNuGet { pname = "Microsoft.CodeAnalysis.Workspaces.MSBuild"; version = "4.8.0"; hash = "sha256-hxpMKC6OF8OaIiSZhAgJ+Rw7M8nqS6xHdUURnRRxJmU="; })
+ (fetchNuGet { pname = "Microsoft.Data.Sqlite.Core"; version = "9.0.4"; hash = "sha256-OJs5gZSKnmDabm6UehVhzYFtsgEmScyNrzbQ+ojiRoI="; })
+ (fetchNuGet { pname = "Microsoft.EntityFrameworkCore"; version = "9.0.4"; hash = "sha256-zjIBv5cnhTVzG7YE+tqSI+havSCAHQdCE3Ha4JjecuQ="; })
+ (fetchNuGet { pname = "Microsoft.EntityFrameworkCore.Abstractions"; version = "9.0.4"; hash = "sha256-CaGhHINjggzaZSmdP51BBM4osWKpbck/2DgoMVCR3bc="; })
+ (fetchNuGet { pname = "Microsoft.EntityFrameworkCore.Analyzers"; version = "9.0.4"; hash = "sha256-Htc1qQ8dTS+tGDaT0gQVsZNtVar3p/LT54YcFkoHTLI="; })
+ (fetchNuGet { pname = "Microsoft.EntityFrameworkCore.Design"; version = "9.0.4"; hash = "sha256-mwW5DH+pX0cRx+wRHoh850u2b6/SxvvhBkKjwe8wRiQ="; })
+ (fetchNuGet { pname = "Microsoft.EntityFrameworkCore.Relational"; version = "9.0.4"; hash = "sha256-cZ8qUtvGgICA8F2XS9sNci14AzLC84mcGv6GibifYKc="; })
+ (fetchNuGet { pname = "Microsoft.EntityFrameworkCore.Sqlite"; version = "9.0.4"; hash = "sha256-9KBVCa/lr6UX2WFrMAU+S4WD0AG51kLI6YP9GxQmZK8="; })
+ (fetchNuGet { pname = "Microsoft.EntityFrameworkCore.Sqlite.Core"; version = "9.0.4"; hash = "sha256-pka9/9W+B4gVY7H7VJ3qCSaEJJW5Thqd6LAdQaIMRQ0="; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Caching.Abstractions"; version = "9.0.4"; hash = "sha256-/VJBbIJzRXjzQ07s4Bicb+WNV0ZAC+/naG2nLVxFvjU="; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Caching.Memory"; version = "9.0.4"; hash = "sha256-5uynkW+dK61Zp1+vs5uW6mwpnkZl7mH/bGSQoGjJH2c="; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Configuration.Abstractions"; version = "9.0.4"; hash = "sha256-5hwq73FCWAJJ8Yb1VHaaryJJhUUiVsetPTrPLlo8N9o="; })
+ (fetchNuGet { pname = "Microsoft.Extensions.DependencyInjection"; version = "9.0.4"; hash = "sha256-ck7PqIL/3vodYky+d7YX218n+detOoEjZeMr1EqTFPg="; })
+ (fetchNuGet { pname = "Microsoft.Extensions.DependencyInjection.Abstractions"; version = "9.0.4"; hash = "sha256-6WcGpsAYRhrpHloEom0oVP7Ff4Gh/O1XWJETJJ3LvEQ="; })
+ (fetchNuGet { pname = "Microsoft.Extensions.DependencyModel"; version = "9.0.4"; hash = "sha256-ziWOK9GykM9HufwYy18FYu6AyOncLOeumi3F/4W3AVE="; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Logging"; version = "9.0.4"; hash = "sha256-Vj+NGOamKeuMrLNUWlVKFFkz7IKGIv6h1A5X4CK9D5E="; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Logging.Abstractions"; version = "9.0.4"; hash = "sha256-n0ZRhQ7U/5Kv1hVqUXGoa5gfrhzcy77yFhfonjq6VFc="; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Options"; version = "9.0.4"; hash = "sha256-QyjtRCG+L9eyH/UWHf/S+7/ZiSOmuGNoKGO9nlXmjxI="; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Primitives"; version = "9.0.4"; hash = "sha256-v/Ygyo1TMTUbnhdQSV2wzD4FOgAEWd1mpESo3kZ557g="; })
+ (fetchNuGet { pname = "Mono.TextTemplating"; version = "3.0.0"; hash = "sha256-VlgGDvgNZb7MeBbIZ4DE2Nn/j2aD9k6XqNHnASUSDr0="; })
+ (fetchNuGet { pname = "SQLitePCLRaw.bundle_e_sqlite3"; version = "2.1.10"; hash = "sha256-kZIWjH/TVTXRIsHPZSl7zoC4KAMBMWmgFYGLrQ15Occ="; })
+ (fetchNuGet { pname = "SQLitePCLRaw.core"; version = "2.1.10"; hash = "sha256-gpZcYwiJVCVwCyJu0R6hYxyMB39VhJDmYh9LxcIVAA8="; })
+ (fetchNuGet { pname = "SQLitePCLRaw.lib.e_sqlite3"; version = "2.1.10"; hash = "sha256-m2v2RQWol+1MNGZsx+G2N++T9BNtQGLLHXUjcwkdCnc="; })
+ (fetchNuGet { pname = "SQLitePCLRaw.provider.e_sqlite3"; version = "2.1.10"; hash = "sha256-MLs3jiETLZ7k/TgkHynZegCWuAbgHaDQKTPB0iNv7Fg="; })
+ (fetchNuGet { pname = "System.CodeDom"; version = "6.0.0"; hash = "sha256-uPetUFZyHfxjScu5x4agjk9pIhbCkt5rG4Axj25npcQ="; })
+ (fetchNuGet { pname = "System.Collections.Immutable"; version = "7.0.0"; hash = "sha256-9an2wbxue2qrtugYES9awshQg+KfJqajhnhs45kQIdk="; })
+ (fetchNuGet { pname = "System.Composition"; version = "7.0.0"; hash = "sha256-YjhxuzuVdAzRBHNQy9y/1ES+ll3QtLcd2o+o8wIyMao="; })
+ (fetchNuGet { pname = "System.Composition.AttributedModel"; version = "7.0.0"; hash = "sha256-3s52Dyk2J66v/B4LLYFBMyXl0I8DFDshjE+sMjW4ubM="; })
+ (fetchNuGet { pname = "System.Composition.Convention"; version = "7.0.0"; hash = "sha256-N4MkkBXSQkcFKsEdcSe6zmyFyMmFOHmI2BNo3wWxftk="; })
+ (fetchNuGet { pname = "System.Composition.Hosting"; version = "7.0.0"; hash = "sha256-7liQGMaVKNZU1iWTIXvqf0SG8zPobRoLsW7q916XC3M="; })
+ (fetchNuGet { pname = "System.Composition.Runtime"; version = "7.0.0"; hash = "sha256-Oo1BxSGLETmdNcYvnkGdgm7JYAnQmv1jY0gL0j++Pd0="; })
+ (fetchNuGet { pname = "System.Composition.TypedParts"; version = "7.0.0"; hash = "sha256-6ZzNdk35qQG3ttiAi4OXrihla7LVP+y2fL3bx40/32s="; })
+ (fetchNuGet { pname = "System.IO.Pipelines"; version = "7.0.0"; hash = "sha256-W2181khfJUTxLqhuAVRhCa52xZ3+ePGOLIPwEN8WisY="; })
+ (fetchNuGet { pname = "System.Memory"; version = "4.5.3"; hash = "sha256-Cvl7RbRbRu9qKzeRBWjavUkseT2jhZBUWV1SPipUWFk="; })
+ (fetchNuGet { pname = "System.Reflection.Metadata"; version = "7.0.0"; hash = "sha256-GwAKQhkhPBYTqmRdG9c9taqrKSKDwyUgOEhWLKxWNPI="; })
+ (fetchNuGet { pname = "System.Runtime.CompilerServices.Unsafe"; version = "6.0.0"; hash = "sha256-bEG1PnDp7uKYz/OgLOWs3RWwQSVYm+AnPwVmAmcgp2I="; })
+ (fetchNuGet { pname = "System.Text.Json"; version = "9.0.4"; hash = "sha256-oIOqfOIIUXXVkfFiTCI9wwIJBETQqF7ZcOJv2iYuq1s="; })
+ (fetchNuGet { pname = "System.Threading.Channels"; version = "7.0.0"; hash = "sha256-Cu0gjQsLIR8Yvh0B4cOPJSYVq10a+3F9pVz/C43CNeM="; })
+]
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..c68aab2
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1745391562,
+ "narHash": "sha256-sPwcCYuiEopaafePqlG826tBhctuJsLx/mhKKM5Fmjo=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "8a2f738d9d1f1d986b5a4cd2fd2061a7127237d7",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..356602b
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,29 @@
+{
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ flake-utils.url = "github:numtide/flake-utils";
+ };
+ outputs = { self, flake-utils, nixpkgs }:
+ flake-utils.lib.eachDefaultSystem (system:
+ let pkgs = nixpkgs.legacyPackages.${system};
+ in {
+ packages = {
+ # Build the backend using 'nix build --extra-experimental-features nix-command --extra-experimental-features flakes .#server'
+ server = pkgs.buildDotnetModule rec {
+ pname = "VPNAuth.Server";
+ version = "1.0.0";
+ src = ./.;
+ projectFile = "VPNAuth.Server/VPNAuth.Server.csproj";
+ dotnet-sdk = pkgs.dotnetCorePackages.sdk_9_0;
+ dotnet-runtime = pkgs.dotnetCorePackages.dotnet_9.aspnetcore;
+
+ # To create or update the deps.nix file run the following steps from the project root:
+ # 1. Create a nix-shell using 'nix-shell -p nuget-to-nix dotnetCorePackages.sdk_9_0'
+ # 2. Run 'rm -rf out/' inside the nix-shell
+ # 3. Run 'dotnet restore VPNAuth.Server --packages out' inside the nix-shell
+ # 4. Run 'nuget-to-nix out > deps.nix' inside the nix-shell
+ nugetDeps = ./deps.nix;
+ };
+ };
+ });
+}