From bb2f88cc5465bf438aa8d5d6bf4ba24398c8e47c Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 6 Jul 2025 21:47:23 +0800 Subject: [PATCH] :sparkles: Account and auth protobuf and client code --- .../DysonNetwork.Shared.csproj | 22 ++++ DysonNetwork.Shared/Protos/account.proto | 28 +++++ DysonNetwork.Shared/Protos/auth.proto | 55 ++++++++++ .../Account/AccountGrpcService.cs | 87 +++++++++++++++ DysonNetwork.Sphere/Auth/AuthGrpcService.cs | 103 ++++++++++++++++++ .../DysonNetwork.Sphere.csproj | 9 ++ DysonNetwork.Sphere/Program.cs | 7 ++ DysonNetwork.Sphere/Protos/account.proto | 28 +++++ DysonNetwork.Sphere/Protos/auth.proto | 55 ++++++++++ DysonNetwork.sln | 6 + 10 files changed, 400 insertions(+) create mode 100644 DysonNetwork.Shared/DysonNetwork.Shared.csproj create mode 100644 DysonNetwork.Shared/Protos/account.proto create mode 100644 DysonNetwork.Shared/Protos/auth.proto create mode 100644 DysonNetwork.Sphere/Account/AccountGrpcService.cs create mode 100644 DysonNetwork.Sphere/Auth/AuthGrpcService.cs create mode 100644 DysonNetwork.Sphere/Protos/account.proto create mode 100644 DysonNetwork.Sphere/Protos/auth.proto diff --git a/DysonNetwork.Shared/DysonNetwork.Shared.csproj b/DysonNetwork.Shared/DysonNetwork.Shared.csproj new file mode 100644 index 0000000..b684f7a --- /dev/null +++ b/DysonNetwork.Shared/DysonNetwork.Shared.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/DysonNetwork.Shared/Protos/account.proto b/DysonNetwork.Shared/Protos/account.proto new file mode 100644 index 0000000..7cb6696 --- /dev/null +++ b/DysonNetwork.Shared/Protos/account.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package dyson_network.sphere.account; + +import "google/protobuf/empty.proto"; + +option csharp_namespace = "DysonNetwork.Sphere.Account.Proto"; + +service AccountService { + // Retrieves the current user's account information + rpc GetAccount(google.protobuf.Empty) returns (AccountResponse); + + // Updates the current user's account information + rpc UpdateAccount(UpdateAccountRequest) returns (AccountResponse); +} + +message AccountResponse { + string id = 1; + string username = 2; + string email = 3; + string display_name = 4; +} + +message UpdateAccountRequest { + // Fields to update + optional string email = 1; + optional string display_name = 2; +} diff --git a/DysonNetwork.Shared/Protos/auth.proto b/DysonNetwork.Shared/Protos/auth.proto new file mode 100644 index 0000000..a119e41 --- /dev/null +++ b/DysonNetwork.Shared/Protos/auth.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package dyson_network.sphere.auth; + +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; + +option csharp_namespace = "DysonNetwork.Sphere.Auth.Proto"; + +service AuthService { + // Standard username/password login + rpc Login(LoginRequest) returns (LoginResponse); + + // Introspects an OAuth 2.0 access token. + rpc IntrospectToken(IntrospectTokenRequest) returns (IntrospectionResponse); + + // Logs out the current session + rpc Logout(google.protobuf.Empty) returns (google.protobuf.Empty); +} + +message LoginRequest { + string username = 1; + string password = 2; + // Optional: for 2FA + string two_factor_code = 3; +} + +message LoginResponse { + string access_token = 1; + string refresh_token = 2; + int64 expires_in = 3; +} + +message IntrospectTokenRequest { + string token = 1; + // Optional: token_type_hint can be "access_token" or "refresh_token" + string token_type_hint = 2; +} + +message IntrospectionResponse { + // Indicates whether or not the token is currently active. + bool active = 1; + // A JSON string containing the claims of the token. + string claims = 2; + // The client identifier for the OAuth 2.0 client that requested the token. + string client_id = 3; + // The username of the resource owner who authorized the token. + string username = 4; + // The scope of the access token. + string scope = 5; + // The time at which the token was issued. + google.protobuf.Timestamp iat = 6; + // The time at which the token expires. + google.protobuf.Timestamp exp = 7; +} diff --git a/DysonNetwork.Sphere/Account/AccountGrpcService.cs b/DysonNetwork.Sphere/Account/AccountGrpcService.cs new file mode 100644 index 0000000..d72e4cc --- /dev/null +++ b/DysonNetwork.Sphere/Account/AccountGrpcService.cs @@ -0,0 +1,87 @@ +using DysonNetwork.Sphere.Account.Proto; +using Grpc.Core; +using Google.Protobuf.WellKnownTypes; +using Microsoft.EntityFrameworkCore; +using DysonNetwork.Sphere.Auth; + +namespace DysonNetwork.Sphere.Account; + +public class AccountGrpcService : DysonNetwork.Sphere.Account.Proto.AccountService.AccountServiceBase +{ + private readonly AppDatabase _db; + private readonly AuthService _auth; + + public AccountGrpcService(AppDatabase db, AuthService auth) + { + _db = db; + _auth = auth; + } + + public override async Task GetAccount(Empty request, ServerCallContext context) + { + var account = await GetAccountFromContext(context); + return ToAccountResponse(account); + } + + public override async Task UpdateAccount(UpdateAccountRequest request, ServerCallContext context) + { + var account = await GetAccountFromContext(context); + + if (request.Email != null) + { + var emailContact = await _db.AccountContacts.FirstOrDefaultAsync(c => c.AccountId == account.Id && c.Type == AccountContactType.Email); + if (emailContact != null) + { + emailContact.Content = request.Email; + } + else + { + account.Contacts.Add(new AccountContact { Type = AccountContactType.Email, Content = request.Email }); + } + } + + if (request.DisplayName != null) + { + account.Nick = request.DisplayName; + } + + await _db.SaveChangesAsync(); + + return ToAccountResponse(account); + } + + private async Task GetAccountFromContext(ServerCallContext context) + { + var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization"); + if (authorizationHeader == null) + { + throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Missing authorization header.")); + } + + var token = authorizationHeader.Value.Replace("Bearer ", ""); + if (!_auth.ValidateToken(token, out var sessionId)) + { + throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid token.")); + } + + var session = await _db.AuthSessions.Include(s => s.Account).ThenInclude(a => a.Contacts).FirstOrDefaultAsync(s => s.Id == sessionId); + if (session == null) + { + throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Session not found.")); + } + + return session.Account; + } + + private AccountResponse ToAccountResponse(DysonNetwork.Sphere.Account.Account account) + { + var emailContact = account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email); + return new AccountResponse + { + Id = account.Id.ToString(), + Username = account.Name, + Email = emailContact?.Content ?? "", + DisplayName = account.Nick + }; + } +} diff --git a/DysonNetwork.Sphere/Auth/AuthGrpcService.cs b/DysonNetwork.Sphere/Auth/AuthGrpcService.cs new file mode 100644 index 0000000..2e6f7b3 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/AuthGrpcService.cs @@ -0,0 +1,103 @@ +using DysonNetwork.Sphere.Auth.Proto; +using Grpc.Core; +using Google.Protobuf.WellKnownTypes; +using DysonNetwork.Sphere.Account; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using System.Text.Json; + +namespace DysonNetwork.Sphere.Auth; + +public class AuthGrpcService : DysonNetwork.Sphere.Auth.Proto.AuthService.AuthServiceBase +{ + private readonly AppDatabase _db; + private readonly AccountService _accounts; + private readonly AuthService _auth; + + public AuthGrpcService(AppDatabase db, AccountService accounts, AuthService auth) + { + _db = db; + _accounts = accounts; + _auth = auth; + } + + public override async Task Login(LoginRequest request, ServerCallContext context) + { + var account = await _accounts.LookupAccount(request.Username); + if (account == null) + { + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found.")); + } + + var factor = await _db.AccountAuthFactors.FirstOrDefaultAsync(f => f.AccountId == account.Id && f.Type == AccountAuthFactorType.Password); + if (factor == null || !factor.VerifyPassword(request.Password)) + { + throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid credentials.")); + } + + var session = new Session + { + LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), + ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), + Account = account, + Challenge = new Challenge() // Create a dummy challenge + }; + + _db.AuthSessions.Add(session); + await _db.SaveChangesAsync(); + + var token = _auth.CreateToken(session); + + return new LoginResponse + { + AccessToken = token, + ExpiresIn = (long)(session.ExpiredAt.Value - session.LastGrantedAt.Value).TotalSeconds + }; + } + + public override async Task IntrospectToken(IntrospectTokenRequest request, ServerCallContext context) + { + if (_auth.ValidateToken(request.Token, out var sessionId)) + { + var session = await _db.AuthSessions + .Include(s => s.Account) + .Include(s => s.Challenge) + .FirstOrDefaultAsync(s => s.Id == sessionId); + + if (session != null) + { + return new IntrospectionResponse + { + Active = true, + Claims = JsonSerializer.Serialize(new { sub = session.AccountId }), + ClientId = session.AppId?.ToString() ?? "", + Username = session.Account.Name, + Scope = string.Join(" ", session.Challenge.Scopes), + Iat = Timestamp.FromDateTime(session.CreatedAt.ToDateTimeUtc()), + Exp = Timestamp.FromDateTime(session.ExpiredAt?.ToDateTimeUtc() ?? DateTime.MaxValue) + }; + } + } + + return new IntrospectionResponse { Active = false }; + } + + public override async Task Logout(Empty request, ServerCallContext context) + { + var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization"); + if (authorizationHeader != null) + { + var token = authorizationHeader.Value.Replace("Bearer ", ""); + if (_auth.ValidateToken(token, out var sessionId)) + { + var session = await _db.AuthSessions.FindAsync(sessionId); + if (session != null) + { + _db.AuthSessions.Remove(session); + await _db.SaveChangesAsync(); + } + } + } + return new Empty(); + } +} diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 0f39fb9..c2cf5c4 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -164,4 +164,13 @@ <_ContentIncludedByDefault Remove="app\publish\package.json" /> + + + + + + + + + diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index 452e629..339ea33 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -17,6 +17,9 @@ builder.Services.AddAppRateLimiting(); builder.Services.AddAppAuthentication(); builder.Services.AddAppSwagger(); +// Add gRPC services +builder.Services.AddGrpc(); + // Add file storage builder.Services.AddAppFileStorage(builder.Configuration); @@ -44,4 +47,8 @@ var tusDiskStore = app.Services.GetRequiredService(); // Configure application middleware pipeline app.ConfigureAppMiddleware(builder.Configuration, tusDiskStore); +// Map gRPC services +app.MapGrpcService(); +app.MapGrpcService(); + app.Run(); \ No newline at end of file diff --git a/DysonNetwork.Sphere/Protos/account.proto b/DysonNetwork.Sphere/Protos/account.proto new file mode 100644 index 0000000..7cb6696 --- /dev/null +++ b/DysonNetwork.Sphere/Protos/account.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; + +package dyson_network.sphere.account; + +import "google/protobuf/empty.proto"; + +option csharp_namespace = "DysonNetwork.Sphere.Account.Proto"; + +service AccountService { + // Retrieves the current user's account information + rpc GetAccount(google.protobuf.Empty) returns (AccountResponse); + + // Updates the current user's account information + rpc UpdateAccount(UpdateAccountRequest) returns (AccountResponse); +} + +message AccountResponse { + string id = 1; + string username = 2; + string email = 3; + string display_name = 4; +} + +message UpdateAccountRequest { + // Fields to update + optional string email = 1; + optional string display_name = 2; +} diff --git a/DysonNetwork.Sphere/Protos/auth.proto b/DysonNetwork.Sphere/Protos/auth.proto new file mode 100644 index 0000000..a119e41 --- /dev/null +++ b/DysonNetwork.Sphere/Protos/auth.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package dyson_network.sphere.auth; + +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; + +option csharp_namespace = "DysonNetwork.Sphere.Auth.Proto"; + +service AuthService { + // Standard username/password login + rpc Login(LoginRequest) returns (LoginResponse); + + // Introspects an OAuth 2.0 access token. + rpc IntrospectToken(IntrospectTokenRequest) returns (IntrospectionResponse); + + // Logs out the current session + rpc Logout(google.protobuf.Empty) returns (google.protobuf.Empty); +} + +message LoginRequest { + string username = 1; + string password = 2; + // Optional: for 2FA + string two_factor_code = 3; +} + +message LoginResponse { + string access_token = 1; + string refresh_token = 2; + int64 expires_in = 3; +} + +message IntrospectTokenRequest { + string token = 1; + // Optional: token_type_hint can be "access_token" or "refresh_token" + string token_type_hint = 2; +} + +message IntrospectionResponse { + // Indicates whether or not the token is currently active. + bool active = 1; + // A JSON string containing the claims of the token. + string claims = 2; + // The client identifier for the OAuth 2.0 client that requested the token. + string client_id = 3; + // The username of the resource owner who authorized the token. + string username = 4; + // The scope of the access token. + string scope = 5; + // The time at which the token was issued. + google.protobuf.Timestamp iat = 6; + // The time at which the token expires. + google.protobuf.Timestamp exp = 7; +} diff --git a/DysonNetwork.sln b/DysonNetwork.sln index ea7c8d0..2c30e2d 100644 --- a/DysonNetwork.sln +++ b/DysonNetwork.sln @@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution compose.yaml = compose.yaml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Shared", "DysonNetwork.Shared\DysonNetwork.Shared.csproj", "{C24C6C96-2D99-4D0C-950B-4C3D8B55E930}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -17,5 +19,9 @@ Global {CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Debug|Any CPU.Build.0 = Debug|Any CPU {CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Release|Any CPU.ActiveCfg = Release|Any CPU {CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Release|Any CPU.Build.0 = Release|Any CPU + {C24C6C96-2D99-4D0C-950B-4C3D8B55E930}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C24C6C96-2D99-4D0C-950B-4C3D8B55E930}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C24C6C96-2D99-4D0C-950B-4C3D8B55E930}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C24C6C96-2D99-4D0C-950B-4C3D8B55E930}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal