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"}"""));
+ }
+ }
}