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&g