Account and auth protobuf and client code

This commit is contained in:
2025-07-06 21:47:23 +08:00
parent 5757526ea5
commit bb2f88cc54
10 changed files with 400 additions and 0 deletions

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Protobuf Include="Protos\*.proto" GrpcServices="Client" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.Net.Client" Version="2.65.0" />
<PackageReference Include="Google.Protobuf" Version="3.27.2" />
<PackageReference Include="Grpc.Tools" Version="2.65.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<AccountResponse> GetAccount(Empty request, ServerCallContext context)
{
var account = await GetAccountFromContext(context);
return ToAccountResponse(account);
}
public override async Task<AccountResponse> 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<DysonNetwork.Sphere.Account.Account> 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
};
}
}

View File

@ -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<LoginResponse> 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<IntrospectionResponse> 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<Empty> 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();
}
}

View File

@ -164,4 +164,13 @@
<_ContentIncludedByDefault Remove="app\publish\package.json" /> <_ContentIncludedByDefault Remove="app\publish\package.json" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.65.0" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\auth.proto" GrpcServices="Server" />
<Protobuf Include="Protos\account.proto" GrpcServices="Server" />
</ItemGroup>
</Project> </Project>

View File

@ -17,6 +17,9 @@ builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication(); builder.Services.AddAppAuthentication();
builder.Services.AddAppSwagger(); builder.Services.AddAppSwagger();
// Add gRPC services
builder.Services.AddGrpc();
// Add file storage // Add file storage
builder.Services.AddAppFileStorage(builder.Configuration); builder.Services.AddAppFileStorage(builder.Configuration);
@ -44,4 +47,8 @@ var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
// Configure application middleware pipeline // Configure application middleware pipeline
app.ConfigureAppMiddleware(builder.Configuration, tusDiskStore); app.ConfigureAppMiddleware(builder.Configuration, tusDiskStore);
// Map gRPC services
app.MapGrpcService<DysonNetwork.Sphere.Auth.AuthGrpcService>();
app.MapGrpcService<DysonNetwork.Sphere.Account.AccountGrpcService>();
app.Run(); app.Run();

View File

@ -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;
}

View File

@ -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;
}

View File

@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
compose.yaml = compose.yaml compose.yaml = compose.yaml
EndProjectSection EndProjectSection
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Shared", "DysonNetwork.Shared\DysonNetwork.Shared.csproj", "{C24C6C96-2D99-4D0C-950B-4C3D8B55E930}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Release|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal