From 70e2503d180a84d7a3f82347307c5e2fbb2034b6 Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 13:12:53 +0800 Subject: [PATCH] feat: add wechat authentication --- Directory.Packages.props | 1 + README.md | 5 + Tiku.Api/Controllers/AuthController.cs | 38 +++ .../Middleware/ExceptionHandlingMiddleware.cs | 1 + Tiku.Application/Auth/AuthContracts.cs | 6 + Tiku.Application/Auth/AuthExceptions.cs | 3 + Tiku.Application/Auth/IAuthService.cs | 8 + Tiku.Application/Auth/IWechatOAuthClient.cs | 26 ++ Tiku.Infrastructure/Auth/AuthService.cs | 274 +++++++++++++++++- Tiku.Infrastructure/Auth/WechatOAuthClient.cs | 115 ++++++++ Tiku.Infrastructure/DependencyInjection.cs | 1 + .../Tiku.Infrastructure.csproj | 1 + Tiku.IntegrationTests/Api/ApiTestFactory.cs | 8 +- .../Api/AuthEndpointTests.cs | 79 +++++ Tiku.UnitTests/Auth/AuthServiceTests.cs | 163 ++++++++++- 15 files changed, 722 insertions(+), 7 deletions(-) create mode 100644 Tiku.Application/Auth/IWechatOAuthClient.cs create mode 100644 Tiku.Infrastructure/Auth/WechatOAuthClient.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 9d4ce5b..be58427 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,6 +22,7 @@ + diff --git a/README.md b/README.md index ac14e09..4d217c7 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ Tiku.Infrastructure/Persistence/Migrations/20260725220742_InitialSchema.cs - 本地认证不依赖 Supabase Auth: - 手机号 + 密码登录 - 短信验证码登录 + - 微信网页 OAuth 登录 + - 微信小程序 `wx.login` 登录 - JWT access token - 数据库 `auth_sessions` refresh/session - `auth_login_events` 登录事件 @@ -88,6 +90,8 @@ Tiku.Infrastructure/Persistence/Migrations/20260725220742_InitialSchema.cs - 第一批认证/租户接口已经建立: - `POST /api/auth/login/password` - `POST /api/auth/login/sms` + - `POST /api/auth/oauth/wechat` + - `POST /api/auth/oauth/wechat-miniapp` - `POST /api/auth/refresh` - `POST /api/auth/logout` - `GET /api/me` @@ -122,6 +126,7 @@ Tiku.Infrastructure/Persistence/Migrations/20260725220742_InitialSchema.cs - 无权限返回 403 - 密码登录成功/失败 - 短信验证码登录成功/失败 + - 微信登录 upsert 用户、身份和租户成员 - 登录后访问当前用户和当前租户 - 登出后旧 access token / refresh token 被拒绝 diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs index 244ce09..16496ee 100644 --- a/Tiku.Api/Controllers/AuthController.cs +++ b/Tiku.Api/Controllers/AuthController.cs @@ -44,6 +44,40 @@ public sealed class AuthController(IAuthService authService) : ControllerBase return Ok(result); } + [AllowAnonymous] + [HttpPost("oauth/wechat")] + public async Task> LoginWithWechatWeb( + WechatLoginHttpRequest request, + CancellationToken cancellationToken) + { + var result = await authService.LoginWithWechatWebAsync( + new WechatLoginRequest( + request.TenantId, + request.Code, + GetIpAddress(), + Request.Headers.UserAgent.ToString()), + cancellationToken); + + return Ok(result); + } + + [AllowAnonymous] + [HttpPost("oauth/wechat-miniapp")] + public async Task> LoginWithWechatMiniApp( + WechatLoginHttpRequest request, + CancellationToken cancellationToken) + { + var result = await authService.LoginWithWechatMiniAppAsync( + new WechatLoginRequest( + request.TenantId, + request.Code, + GetIpAddress(), + Request.Headers.UserAgent.ToString()), + cancellationToken); + + return Ok(result); + } + [AllowAnonymous] [HttpPost("refresh")] public async Task> Refresh( @@ -89,4 +123,8 @@ public sealed record SmsLoginHttpRequest( string Phone, string Code); +public sealed record WechatLoginHttpRequest( + Guid TenantId, + string Code); + public sealed record RefreshHttpRequest(string RefreshToken); diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index c7cce9f..8d88cc0 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -44,6 +44,7 @@ public sealed class ExceptionHandlingMiddleware( { "tenant_access_denied" => StatusCodes.Status403Forbidden, "sms_rate_limited" => StatusCodes.Status429TooManyRequests, + "auth_provider_not_configured" => StatusCodes.Status503ServiceUnavailable, "session_revoked" => StatusCodes.Status401Unauthorized, _ => StatusCodes.Status401Unauthorized }; diff --git a/Tiku.Application/Auth/AuthContracts.cs b/Tiku.Application/Auth/AuthContracts.cs index 38075d8..a07b420 100644 --- a/Tiku.Application/Auth/AuthContracts.cs +++ b/Tiku.Application/Auth/AuthContracts.cs @@ -36,6 +36,12 @@ public sealed record SmsLoginRequest( string? IpAddress, string? UserAgent); +public sealed record WechatLoginRequest( + Guid TenantId, + string Code, + string? IpAddress, + string? UserAgent); + public sealed record RefreshSessionRequest( string RefreshToken, string? IpAddress, diff --git a/Tiku.Application/Auth/AuthExceptions.cs b/Tiku.Application/Auth/AuthExceptions.cs index 2b797f8..8c5552a 100644 --- a/Tiku.Application/Auth/AuthExceptions.cs +++ b/Tiku.Application/Auth/AuthExceptions.cs @@ -16,3 +16,6 @@ public sealed class SessionRevokedException() public sealed class SmsRateLimitedException() : AuthException("sms_rate_limited", "SMS verification requests are rate limited."); + +public sealed class AuthProviderNotConfiguredException(string provider) + : AuthException("auth_provider_not_configured", $"The {provider} auth provider is not configured."); diff --git a/Tiku.Application/Auth/IAuthService.cs b/Tiku.Application/Auth/IAuthService.cs index 6549901..ed3b9ee 100644 --- a/Tiku.Application/Auth/IAuthService.cs +++ b/Tiku.Application/Auth/IAuthService.cs @@ -10,6 +10,14 @@ public interface IAuthService SmsLoginRequest request, CancellationToken cancellationToken = default); + Task LoginWithWechatWebAsync( + WechatLoginRequest request, + CancellationToken cancellationToken = default); + + Task LoginWithWechatMiniAppAsync( + WechatLoginRequest request, + CancellationToken cancellationToken = default); + Task RefreshAsync( RefreshSessionRequest request, CancellationToken cancellationToken = default); diff --git a/Tiku.Application/Auth/IWechatOAuthClient.cs b/Tiku.Application/Auth/IWechatOAuthClient.cs new file mode 100644 index 0000000..35d5901 --- /dev/null +++ b/Tiku.Application/Auth/IWechatOAuthClient.cs @@ -0,0 +1,26 @@ +namespace Tiku.Application.Auth; + +public sealed record WechatProviderOptions( + string AppId, + string AppSecret); + +public sealed record WechatIdentity( + string OpenId, + string? UnionId, + string? Nickname, + string? AvatarUrl, + string? SessionKey, + string RawJson); + +public interface IWechatOAuthClient +{ + Task ExchangeWebCodeAsync( + WechatProviderOptions options, + string code, + CancellationToken cancellationToken = default); + + Task ExchangeMiniAppCodeAsync( + WechatProviderOptions options, + string code, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Infrastructure/Auth/AuthService.cs b/Tiku.Infrastructure/Auth/AuthService.cs index e2a9733..4c2b550 100644 --- a/Tiku.Infrastructure/Auth/AuthService.cs +++ b/Tiku.Infrastructure/Auth/AuthService.cs @@ -11,10 +11,16 @@ public sealed class AuthService( TikuDbContext dbContext, IPasswordHasher passwordHasher, ISmsVerificationService smsVerificationService, - ISessionService sessionService) : IAuthService + ISessionService sessionService, + IWechatOAuthClient wechatOAuthClient) : IAuthService { private const string PasswordProvider = "password"; private const string SmsProvider = "sms"; + private const string WechatWebProvider = "wechat_web"; + private const string WechatMiniAppProvider = "wechat-miniapp"; + private static readonly string[] WechatWebProviderAliases = ["wechat_web", "wechat-web", "wechat"]; + private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; + private static readonly string[] WechatIdentityProviders = ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"]; public async Task LoginWithPasswordAsync( PasswordLoginRequest request, @@ -118,6 +124,30 @@ public sealed class AuthService( cancellationToken); } + public Task LoginWithWechatWebAsync( + WechatLoginRequest request, + CancellationToken cancellationToken = default) + { + return LoginWithWechatAsync( + request, + WechatWebProvider, + WechatWebProviderAliases, + (options, code, token) => wechatOAuthClient.ExchangeWebCodeAsync(options, code, token), + cancellationToken); + } + + public Task LoginWithWechatMiniAppAsync( + WechatLoginRequest request, + CancellationToken cancellationToken = default) + { + return LoginWithWechatAsync( + request, + WechatMiniAppProvider, + WechatMiniAppProviderAliases, + (options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token), + cancellationToken); + } + public async Task RefreshAsync( RefreshSessionRequest request, CancellationToken cancellationToken = default) @@ -247,6 +277,210 @@ public sealed class AuthService( tokens); } + private async Task LoginWithWechatAsync( + WechatLoginRequest request, + string provider, + IReadOnlyList providerAliases, + Func> exchangeCodeAsync, + CancellationToken cancellationToken) + { + var config = await LoadWechatProviderOptionsAsync( + request.TenantId, + provider, + providerAliases, + cancellationToken); + + WechatIdentity identity; + try + { + identity = await exchangeCodeAsync(config, request.Code, cancellationToken); + } + catch (AuthException exception) + { + await AddLoginEventAsync( + request.TenantId, + null, + provider, + null, + AuthLoginResult.Failed, + exception.Code, + request.IpAddress, + request.UserAgent, + cancellationToken); + throw; + } + + var providerSubject = $"{config.AppId}:{identity.OpenId}"; + var user = await UpsertWechatUserAsync( + provider, + providerSubject, + config.AppId, + identity, + cancellationToken); + await EnsureTenantMembershipAsync( + request.TenantId, + user.Id, + cancellationToken); + + return await CompleteSuccessfulLoginAsync( + request.TenantId, + user, + provider, + identity.OpenId, + request.IpAddress, + request.UserAgent, + cancellationToken); + } + + private async Task LoadWechatProviderOptionsAsync( + Guid tenantId, + string provider, + IReadOnlyList aliases, + CancellationToken cancellationToken) + { + var rows = await dbContext.TenantAuthProviders + .Where(entity => + entity.TenantId == tenantId && + aliases.Contains(entity.Provider) && + (entity.Status == TenantAuthProviderStatus.Active || + entity.Status == TenantAuthProviderStatus.Testing)) + .ToListAsync(cancellationToken); + var row = aliases + .Select(alias => rows.FirstOrDefault(entity => entity.Provider == alias)) + .FirstOrDefault(entity => entity is not null); + + if (row is null) + { + throw new AuthProviderNotConfiguredException(provider); + } + + var appId = GetJsonString(row.ConfigPublic, "appId", "clientId"); + var appSecret = GetJsonString(row.ConfigPublic, "appSecret", "clientSecret", "secret"); + if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret)) + { + throw new AuthProviderNotConfiguredException(provider); + } + + return new WechatProviderOptions(appId, appSecret); + } + + private async Task UpsertWechatUserAsync( + string provider, + string providerSubject, + string appId, + WechatIdentity wechatIdentity, + CancellationToken cancellationToken) + { + var existingIdentity = await dbContext.UserIdentities + .SingleOrDefaultAsync( + identity => + identity.Provider == provider && + identity.ProviderSubject == providerSubject, + cancellationToken); + var user = existingIdentity is null + ? await FindUserByWechatUnionIdAsync(wechatIdentity.UnionId, cancellationToken) + : await dbContext.Users.FindAsync([existingIdentity.UserId], cancellationToken); + + if (user is null) + { + user = new User + { + Name = wechatIdentity.Nickname, + AvatarUrl = wechatIdentity.AvatarUrl, + PrimaryRole = "student", + RawProfile = CreateWechatRawProfile(wechatIdentity) + }; + dbContext.Users.Add(user); + } + else + { + user.Name = string.IsNullOrWhiteSpace(user.Name) ? wechatIdentity.Nickname : user.Name; + user.AvatarUrl = string.IsNullOrWhiteSpace(user.AvatarUrl) ? wechatIdentity.AvatarUrl : user.AvatarUrl; + } + + if (existingIdentity is null) + { + existingIdentity = new UserIdentity + { + UserId = user.Id, + Provider = provider, + ProviderSubject = providerSubject + }; + dbContext.UserIdentities.Add(existingIdentity); + } + + existingIdentity.UserId = user.Id; + existingIdentity.OpenId = wechatIdentity.OpenId; + existingIdentity.UnionId = wechatIdentity.UnionId; + existingIdentity.SecretPayload = CreateWechatSecretPayload(appId, wechatIdentity); + await dbContext.SaveChangesAsync(cancellationToken); + + return user; + } + + private async Task FindUserByWechatUnionIdAsync( + string? unionId, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(unionId)) + { + return null; + } + + var identity = await dbContext.UserIdentities + .Where(entity => + entity.UnionId == unionId && + WechatIdentityProviders.Contains(entity.Provider)) + .OrderBy(entity => entity.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + return identity is null + ? null + : await dbContext.Users.FindAsync([identity.UserId], cancellationToken); + } + + private async Task EnsureTenantMembershipAsync( + Guid tenantId, + Guid userId, + CancellationToken cancellationToken) + { + var activeMembershipExists = await dbContext.TenantMemberships.AnyAsync( + membership => + membership.TenantId == tenantId && + membership.UserId == userId && + membership.Status == MembershipStatus.Active, + cancellationToken); + + if (activeMembershipExists) + { + return; + } + + var studentMembership = await dbContext.TenantMemberships + .FirstOrDefaultAsync( + membership => + membership.TenantId == tenantId && + membership.UserId == userId && + membership.Role == TenantRole.Student, + cancellationToken); + if (studentMembership is null) + { + dbContext.TenantMemberships.Add(new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }); + } + else + { + studentMembership.Status = MembershipStatus.Active; + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + private async Task FindActiveMembershipAsync( Guid tenantId, Guid userId, @@ -295,4 +529,42 @@ public sealed class AuthService( property.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(passwordHash = property.GetString() ?? string.Empty); } + + private static string? GetJsonString(JsonElement element, params string[] names) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var name in names) + { + if (element.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(property.GetString())) + { + return property.GetString()!.Trim(); + } + } + + return null; + } + + private static JsonElement CreateWechatRawProfile(WechatIdentity identity) + { + using var document = JsonDocument.Parse(identity.RawJson); + return document.RootElement.Clone(); + } + + private static JsonElement CreateWechatSecretPayload(string appId, WechatIdentity identity) + { + var payload = new + { + appId, + sessionKey = identity.SessionKey, + raw = JsonSerializer.Deserialize(identity.RawJson), + updatedAt = DateTimeOffset.UtcNow + }; + return JsonSerializer.SerializeToElement(payload); + } } diff --git a/Tiku.Infrastructure/Auth/WechatOAuthClient.cs b/Tiku.Infrastructure/Auth/WechatOAuthClient.cs new file mode 100644 index 0000000..d2183f0 --- /dev/null +++ b/Tiku.Infrastructure/Auth/WechatOAuthClient.cs @@ -0,0 +1,115 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Tiku.Application.Auth; + +namespace Tiku.Infrastructure.Auth; + +public sealed class WechatOAuthClient(HttpClient httpClient) : IWechatOAuthClient +{ + private static readonly Uri WebAccessTokenEndpoint = new("https://api.weixin.qq.com/sns/oauth2/access_token"); + private static readonly Uri WebUserInfoEndpoint = new("https://api.weixin.qq.com/sns/userinfo"); + private static readonly Uri MiniAppCode2SessionEndpoint = new("https://api.weixin.qq.com/sns/jscode2session"); + + public async Task ExchangeWebCodeAsync( + WechatProviderOptions options, + string code, + CancellationToken cancellationToken = default) + { + var tokenUri = BuildUri(WebAccessTokenEndpoint, new Dictionary + { + ["appid"] = options.AppId, + ["secret"] = options.AppSecret, + ["code"] = code, + ["grant_type"] = "authorization_code" + }); + using var tokenDocument = await GetWechatJsonAsync(tokenUri, cancellationToken); + var token = tokenDocument.RootElement; + var accessToken = RequiredString(token, "access_token", "wechat_access_token_missing"); + var openId = RequiredString(token, "openid", "wechat_openid_missing"); + var unionId = OptionalString(token, "unionid"); + + var userInfoUri = BuildUri(WebUserInfoEndpoint, new Dictionary + { + ["access_token"] = accessToken, + ["openid"] = openId, + ["lang"] = "zh_CN" + }); + using var userDocument = await GetWechatJsonAsync(userInfoUri, cancellationToken); + var user = userDocument.RootElement; + + return new WechatIdentity( + openId, + OptionalString(user, "unionid") ?? unionId, + OptionalString(user, "nickname"), + OptionalString(user, "headimgurl"), + null, + user.GetRawText()); + } + + public async Task ExchangeMiniAppCodeAsync( + WechatProviderOptions options, + string code, + CancellationToken cancellationToken = default) + { + var uri = BuildUri(MiniAppCode2SessionEndpoint, new Dictionary + { + ["appid"] = options.AppId, + ["secret"] = options.AppSecret, + ["js_code"] = code, + ["grant_type"] = "authorization_code" + }); + using var document = await GetWechatJsonAsync(uri, cancellationToken); + var root = document.RootElement; + + return new WechatIdentity( + RequiredString(root, "openid", "wechat_openid_missing"), + OptionalString(root, "unionid"), + null, + null, + RequiredString(root, "session_key", "wechat_session_key_missing"), + root.GetRawText()); + } + + private async Task GetWechatJsonAsync( + Uri uri, + CancellationToken cancellationToken) + { + using var response = await httpClient.GetAsync(uri, cancellationToken); + response.EnsureSuccessStatusCode(); + var document = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new InvalidCredentialsException("wechat_empty_response"); + + if (document.RootElement.TryGetProperty("errcode", out var errcode) && + errcode.ValueKind == JsonValueKind.Number && + errcode.GetInt32() != 0) + { + throw new InvalidCredentialsException("wechat_code_exchange_failed"); + } + + return document; + } + + private static Uri BuildUri(Uri endpoint, IReadOnlyDictionary query) + { + var builder = new UriBuilder(endpoint); + builder.Query = string.Join( + '&', + query.Select(pair => + $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")); + return builder.Uri; + } + + private static string RequiredString(JsonElement element, string property, string errorCode) + { + return OptionalString(element, property) ?? throw new InvalidCredentialsException(errorCode); + } + + private static string? OptionalString(JsonElement element, string property) + { + return element.TryGetProperty(property, out var value) && + value.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(value.GetString()) + ? value.GetString()!.Trim() + : null; + } +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 70a8a14..31b5bef 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -26,6 +26,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddHttpClient(); services.AddScoped(); return services; diff --git a/Tiku.Infrastructure/Tiku.Infrastructure.csproj b/Tiku.Infrastructure/Tiku.Infrastructure.csproj index 4f30f82..ddcaad4 100644 --- a/Tiku.Infrastructure/Tiku.Infrastructure.csproj +++ b/Tiku.Infrastructure/Tiku.Infrastructure.csproj @@ -9,6 +9,7 @@ + diff --git a/Tiku.IntegrationTests/Api/ApiTestFactory.cs b/Tiku.IntegrationTests/Api/ApiTestFactory.cs index 89b5f6d..833b8eb 100644 --- a/Tiku.IntegrationTests/Api/ApiTestFactory.cs +++ b/Tiku.IntegrationTests/Api/ApiTestFactory.cs @@ -2,13 +2,14 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Npgsql; +using Tiku.Application.Auth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; -public sealed class ApiTestFactory : WebApplicationFactory +public sealed class ApiTestFactory(IWechatOAuthClient? wechatOAuthClient = null) : WebApplicationFactory { private readonly string databaseName = Guid.NewGuid().ToString(); @@ -28,6 +29,11 @@ public sealed class ApiTestFactory : WebApplicationFactory services.AddDbContext(options => options.UseInMemoryDatabase(databaseName)); + + if (wechatOAuthClient is not null) + { + services.AddSingleton(wechatOAuthClient); + } }); } diff --git a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs index 9171709..05a1b5a 100644 --- a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Auth; using Tiku.Api.Controllers; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; @@ -91,6 +92,53 @@ public sealed class AuthEndpointTests Assert.Equal(HttpStatusCode.Unauthorized, refreshResponse.StatusCode); } + [Fact] + public async Task Wechat_miniapp_login_can_access_current_user() + { + await using var factory = new ApiTestFactory(new FakeWechatOAuthClient()); + var tenantId = Guid.NewGuid(); + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Wechat Tenant" + }, + new TenantAuthProvider + { + TenantId = tenantId, + Provider = "wechat-miniapp", + Status = TenantAuthProviderStatus.Testing, + ConfigPublic = JsonSerializer.SerializeToElement(new + { + appId = "wx-app-id", + appSecret = "wx-app-secret" + }) + }); + using var client = factory.CreateClient(); + + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/oauth/wechat-miniapp", + new WechatLoginHttpRequest(tenantId, "wx-code")); + var loginJson = await ReadJsonAsync(loginResponse); + var accessToken = loginJson.RootElement + .GetProperty("tokens") + .GetProperty("accessToken") + .GetString(); + + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + var meResponse = await client.GetAsync("/api/me"); + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); + Assert.Contains(dbContext.UserIdentities, identity => + identity.Provider == "wechat-miniapp" && + identity.OpenId == "mini-open-id" && + identity.UnionId == "union-id"); + } + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync( ApiTestFactory factory) { @@ -163,4 +211,35 @@ public sealed class AuthEndpointTests $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); return document.RootElement.Clone(); } + + private sealed class FakeWechatOAuthClient : IWechatOAuthClient + { + public Task ExchangeWebCodeAsync( + WechatProviderOptions options, + string code, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new WechatIdentity( + "web-open-id", + "union-id", + "Wechat User", + "https://example.test/avatar.png", + null, + """{"openid":"web-open-id","unionid":"union-id"}""")); + } + + public Task ExchangeMiniAppCodeAsync( + WechatProviderOptions options, + string code, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new WechatIdentity( + "mini-open-id", + "union-id", + null, + null, + "session-key", + """{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}""")); + } + } } diff --git a/Tiku.UnitTests/Auth/AuthServiceTests.cs b/Tiku.UnitTests/Auth/AuthServiceTests.cs index a08d7f4..db006b6 100644 --- a/Tiku.UnitTests/Auth/AuthServiceTests.cs +++ b/Tiku.UnitTests/Auth/AuthServiceTests.cs @@ -152,9 +152,94 @@ public sealed class AuthServiceTests await Assert.ThrowsAsync(() => service.RefreshAsync(new RefreshSessionRequest( - login.Tokens.RefreshToken, - null, - null))); + login.Tokens.RefreshToken, + null, + null))); + } + + [Fact] + public async Task Wechat_miniapp_login_creates_user_identity_membership_and_session() + { + await using var context = CreateContext(); + var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp"); + var service = CreateAuthService( + context, + new FakeWechatOAuthClient( + MiniAppIdentity: new WechatIdentity( + "mini-open-id", + "union-id", + null, + null, + "session-key", + """{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}"""))); + + var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest( + tenantId, + "wx-code", + null, + null)); + + Assert.Equal(tenantId, result.Tenant.TenantId); + Assert.Single(context.Users); + Assert.Contains(context.UserIdentities, identity => + identity.Provider == "wechat-miniapp" && + identity.ProviderSubject == "wx-app-id:mini-open-id" && + identity.OpenId == "mini-open-id" && + identity.UnionId == "union-id"); + Assert.Contains(context.TenantMemberships, membership => + membership.TenantId == tenantId && + membership.UserId == result.UserId && + membership.Status == MembershipStatus.Active); + Assert.Single(context.AuthSessions); + } + + [Fact] + public async Task Wechat_union_id_reuses_existing_user_across_providers() + { + await using var context = CreateContext(); + var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp"); + context.TenantAuthProviders.Add(new TenantAuthProvider + { + TenantId = tenantId, + Provider = "wechat_web", + Status = TenantAuthProviderStatus.Testing, + ConfigPublic = WechatProviderConfig() + }); + var user = new User + { + Id = Guid.NewGuid(), + Name = "Existing" + }; + context.Users.Add(user); + context.UserIdentities.Add(new UserIdentity + { + UserId = user.Id, + Provider = "wechat_web", + ProviderSubject = "wx-app-id:web-open-id", + OpenId = "web-open-id", + UnionId = "same-union" + }); + await context.SaveChangesAsync(); + var service = CreateAuthService( + context, + new FakeWechatOAuthClient( + MiniAppIdentity: new WechatIdentity( + "mini-open-id", + "same-union", + null, + null, + "session-key", + """{"openid":"mini-open-id","unionid":"same-union","session_key":"session-key"}"""))); + + var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest( + tenantId, + "wx-code", + null, + null)); + + Assert.Equal(user.Id, result.UserId); + Assert.Single(context.Users); + Assert.Equal(2, context.UserIdentities.Count()); } private static TikuDbContext CreateContext() @@ -166,7 +251,9 @@ public sealed class AuthServiceTests return new TikuDbContext(options); } - private static IAuthService CreateAuthService(TikuDbContext context) + private static IAuthService CreateAuthService( + TikuDbContext context, + IWechatOAuthClient? wechatOAuthClient = null) { var tokenService = new TokenService(Options.Create(JwtOptions)); var sessionService = new SessionService(context, tokenService, Options.Create(JwtOptions)); @@ -176,7 +263,40 @@ public sealed class AuthServiceTests context, new PasswordHasher(), smsService, - sessionService); + sessionService, + wechatOAuthClient ?? new FakeWechatOAuthClient()); + } + + private static async Task SeedTenantWithWechatProviderAsync( + TikuDbContext context, + string provider) + { + var tenant = new Tenant + { + Id = Guid.NewGuid(), + Slug = Guid.NewGuid().ToString("N"), + Name = "Wechat Tenant" + }; + context.Tenants.Add(tenant); + context.TenantAuthProviders.Add(new TenantAuthProvider + { + TenantId = tenant.Id, + Provider = provider, + Status = TenantAuthProviderStatus.Testing, + ConfigPublic = WechatProviderConfig() + }); + await context.SaveChangesAsync(); + + return tenant.Id; + } + + private static JsonElement WechatProviderConfig() + { + return JsonSerializer.SerializeToElement(new + { + appId = "wx-app-id", + appSecret = "wx-app-secret" + }); } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedUserAsync( @@ -226,4 +346,37 @@ public sealed class AuthServiceTests $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); return document.RootElement.Clone(); } + + private sealed class FakeWechatOAuthClient( + WechatIdentity? WebIdentity = null, + WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient + { + public Task ExchangeWebCodeAsync( + WechatProviderOptions options, + string code, + CancellationToken cancellationToken = default) + { + return Task.FromResult(WebIdentity ?? new WechatIdentity( + "web-open-id", + "union-id", + "Wechat User", + "https://example.test/avatar.png", + null, + """{"openid":"web-open-id","unionid":"union-id"}""")); + } + + public Task ExchangeMiniAppCodeAsync( + WechatProviderOptions options, + string code, + CancellationToken cancellationToken = default) + { + return Task.FromResult(MiniAppIdentity ?? new WechatIdentity( + "mini-open-id", + "union-id", + null, + null, + "session-key", + """{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}""")); + } + } }