diff options
25 files changed, 894 insertions, 208 deletions
@@ -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&g |