私の質問はやや複雑なので、私が苦労していることをうまくレイアウトしようと努力しています。
目標
ユーザーがAPIを備えたユーザー名/パスワードまたはソーシャル(Facebook、Twitter、Googleなど)を介して登録およびサインインできるASP.NET Webサイトがあります。このAPIは、[Authorize]
でロックダウンする必要があります。 APIは、ユーザー名/パスワードまたはソーシャル(Facebook、Twitter、Googleなど)でサインインできるモバイルクライアント(Android、iOSなど)からアクセスできる必要があります。
背景
そのため、目標から1つまたは2つのことを実行できるサイトを作成しましたが、すべてを同時に実行することはできません。素晴らしい例がオンラインにあり、ユーザーがソーシャルアプリを介して登録およびサインインする方法を示すVSプロジェクトに組み込まれた例がありますが、それらはWebサイト専用であり、モバイル用ではありません。 Androidアプリはユーザー名/パスワードを使用してそのAPIで認証しますが、OAuthまたはソーシャル認証情報は使用しません。
これを page のリファレンスとして使用し始めましたが、それをどのように取得して、Webサイトへのログインとモバイルアプリへのログインで機能させるかはわかりません。
この男 はとても簡単に聞こえますが、このコードは表示しません。
質問
私の目標を達成できるチュートリアルまたはGitHubの例はありますか?基本的には、ユーザーがユーザー名/パスワードを登録したり、ソーシャルアカウントを使用したり、ユーザーにモバイルデバイスを介して同じこと(登録とログイン)を行えるWebサイトが必要です。モバイルデバイスは基本的にAPIを使用してデータをプッシュ/プルしますが、APIにソーシャルログインを組み込む方法はわかりません。 OAuthを使用してそのルートに行く必要があると思いますが、Webとモバイルの両方でこれを行う方法を示す良い例が見つかりません。
または、適切な解決策は、WebページをすべてCookie認証にし、APIを別個の「Webサイト」にし、すべてトークン認証にし、両方を同じデータベースに関連付けることですか?
ASP.NET Identityを使用して、ASP.NET MVCアプリケーション内でこのタスクを正常に完了しましたが、次に言及する問題にぶつかりました:モバイルアプリがネイティブに対話できるように、Web APIを使用してこれを動作させる必要があります。
私はあなたがリンクした記事に慣れていませんでしたが、それを読んだ後、多くの作業とコードが不要であり、ASP.NET Identityに既に存在する機能を複雑にしていることに気付きました。
ここに私の推奨事項があり、MVC5を取り巻くパッケージ(新しいMVC6 vNextではない)と同等のASP.NET Identity V2を使用していると想定しています。これにより、APIを介したWebサイトとモバイルアプリケーションの両方が、ローカルログイン(ユーザー名/パスワード)と外部OAuthプロバイダーの両方で、WebサイトのMVC WebビューとWeb API呼び出しの両方で認証できますモバイルアプリケーションから:
ステップ1.プロジェクトを作成するとき、MVCに必要なパッケージとWeb APIの両方が含まれていることを確認してください。 [ASP.NETプロジェクトの選択]ダイアログには、チェックボックスを選択するオプションがあり、MVCとWeb APIの両方がチェックされていることを確認します。プロジェクトの作成時にまだこれを行っていない場合は、依存関係とテンプレートコードを検索して手動で追加するのではなく、新しいプロジェクトを作成し、既存のコードを移行することをお勧めします。
ステップ2. Startup.Auth.csファイル内で、Cookie認証を使用し、外部サインインCookieを許可し、OAuthベアラートークンをサポートするようにOWINに指示するコードが必要です(これがWeb APIこれらは、私の作業プロジェクトのコードベースからの関連する抜粋です:
Startup.Auth.cs
// Enable the application to use a cookie to store information for the signed in user
// and to use a cookie to temporarily store information about a user logging in with a third party login provider
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = new PathString("/account/login"),
Provider = new CookieAuthenticationProvider
{
// Enables the application to validate the security stamp when the user logs in.
// This is a security feature which is used when you change a password or add an external login to your account.
OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
validateInterval: TimeSpan.FromMinutes(30),
regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager))
}
});
app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
// Configure the application for OAuth based flow
PublicClientId = "self";
OAuthOptions = new OAuthAuthorizationServerOptions
{
TokenEndpointPath = new PathString("/token"),
Provider = new ApplicationOAuthProvider(PublicClientId),
AuthorizeEndpointPath = new PathString("/api/account/externallogin"),
AccessTokenExpireTimeSpan = TimeSpan.FromDays(14),
//AllowInsecureHttp = false
};
// Enable the application to use bearer tokens to authenticate users
app.UseOAuthBearerTokens(OAuthOptions);
app.UseTwitterAuthentication(
consumerKey: "Twitter API Key",
consumerSecret: "Twitter API Secret");
app.UseFacebookAuthentication(
appId: "Facebook AppId",
appSecret: "Facebook AppSecret");
上記のコードでは、現在、外部認証プロバイダーとしてTwitterとFacebookをサポートしています。ただし、app.UserXYZProvider呼び出しと追加のライブラリを使用して外部プロバイダーを追加すると、ここで提供するコードをプラグアンドプレイできます。
ステップ3. WebApiConfig.csファイル内で、HttpConfigurationを構成して、デフォルトのホスト認証を抑制し、OAuthベアラートークンをサポートする必要があります。これは、MVCとWebの間で認証タイプを区別するこの方法では、Webサイトで一般的なCookieフローを使用できますが、アプリケーションは、文句やその他の問題なしにWeb APIからOAuthの形式でベアラートークンを受け入れます。
WebApiConfig.cs
// Web API configuration and services
// Configure Web API to use only bearer token authentication.
config.SuppressDefaultHostAuthentication();
config.Filters.Add(new HostAuthenticationFilter(OAuthDefaults.AuthenticationType));
ステップ4. MVCとWeb APIの両方にAccountController(または同等の目的のコントローラー)が必要です。私のプロジェクトには、2つのAccountControllerファイルがあります。1つはベースControllerクラスを継承するMVCコントローラー、もう1つは物事をきれいに保つためにControllers.API名前空間にあるApiControllerを継承するAccountControllerです。 Web APIおよびMVCプロジェクトの標準テンプレートAccountControllerコードを使用しています。アカウントコントローラーのAPIバージョンは次のとおりです。
AccountController.cs(Controllers.API名前空間)
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Threading.Tasks;
using System.Web;
using System.Web.Http;
using System.Web.Http.ModelBinding;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OAuth;
using Disco.Models.API;
using Disco.Providers;
using Disco.Results;
using Schloss.AspNet.Identity.Neo4j;
using Disco.Results.API;
namespace Disco.Controllers.API
{
[Authorize]
[RoutePrefix("api/account")]
public class AccountController : ApiController
{
private const string LocalLoginProvider = "Local";
private ApplicationUserManager _userManager;
public AccountController()
{
}
public AccountController(ApplicationUserManager userManager,
ISecureDataFormat<AuthenticationTicket> accessTokenFormat)
{
UserManager = userManager;
AccessTokenFormat = accessTokenFormat;
}
public ApplicationUserManager UserManager
{
get
{
return _userManager ?? Request.GetOwinContext().GetUserManager<ApplicationUserManager>();
}
private set
{
_userManager = value;
}
}
public ISecureDataFormat<AuthenticationTicket> AccessTokenFormat { get; private set; }
// GET account/UserInfo
[HostAuthentication(DefaultAuthenticationTypes.ExternalBearer)]
[Route("userinfo")]
public UserInfoViewModel GetUserInfo()
{
ExternalLoginData externalLogin = ExternalLoginData.FromIdentity(User.Identity as ClaimsIdentity);
return new UserInfoViewModel
{
Email = User.Identity.GetUserName(),
HasRegistered = externalLogin == null,
LoginProvider = externalLogin != null ? externalLogin.LoginProvider : null
};
}
// POST account/Logout
[Route("logout")]
public IHttpActionResult Logout()
{
Authentication.SignOut(CookieAuthenticationDefaults.AuthenticationType);
return Ok();
}
// GET account/ManageInfo?returnUrl=%2F&generateState=true
[Route("manageinfo")]
public async Task<ManageInfoViewModel> GetManageInfo(string returnUrl, bool generateState = false)
{
IdentityUser user = await UserManager.FindByIdAsync(User.Identity.GetUserId());
if (user == null)
{
return null;
}
List<UserLoginInfoViewModel> logins = new List<UserLoginInfoViewModel>();
foreach (UserLoginInfo linkedAccount in await UserManager.GetLoginsAsync(User.Identity.GetUserId()))
{
logins.Add(new UserLoginInfoViewModel
{
LoginProvider = linkedAccount.LoginProvider,
ProviderKey = linkedAccount.ProviderKey
});
}
if (user.PasswordHash != null)
{
logins.Add(new UserLoginInfoViewModel
{
LoginProvider = LocalLoginProvider,
ProviderKey = user.UserName,
});
}
return new ManageInfoViewModel
{
LocalLoginProvider = LocalLoginProvider,
Email = user.UserName,
Logins = logins,
ExternalLoginProviders = GetExternalLogins(returnUrl, generateState)
};
}
// POST account/ChangePassword
[Route("changepassword")]
public async Task<IHttpActionResult> ChangePassword(ChangePasswordBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
IdentityResult result = await UserManager.ChangePasswordAsync(User.Identity.GetUserId(), model.OldPassword,
model.NewPassword);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
// POST account/SetPassword
[Route("setpassword")]
public async Task<IHttpActionResult> SetPassword(SetPasswordBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
IdentityResult result = await UserManager.AddPasswordAsync(User.Identity.GetUserId(), model.NewPassword);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
// POST account/AddExternalLogin
[Route("addexternallogin")]
public async Task<IHttpActionResult> AddExternalLogin(AddExternalLoginBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);
AuthenticationTicket ticket = AccessTokenFormat.Unprotect(model.ExternalAccessToken);
if (ticket == null || ticket.Identity == null || (ticket.Properties != null
&& ticket.Properties.ExpiresUtc.HasValue
&& ticket.Properties.ExpiresUtc.Value < DateTimeOffset.UtcNow))
{
return BadRequest("External login failure.");
}
ExternalLoginData externalData = ExternalLoginData.FromIdentity(ticket.Identity);
if (externalData == null)
{
return BadRequest("The external login is already associated with an account.");
}
IdentityResult result = await UserManager.AddLoginAsync(User.Identity.GetUserId(),
new UserLoginInfo(externalData.LoginProvider, externalData.ProviderKey));
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
// POST account/RemoveLogin
[Route("removelogin")]
public async Task<IHttpActionResult> RemoveLogin(RemoveLoginBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
IdentityResult result;
if (model.LoginProvider == LocalLoginProvider)
{
result = await UserManager.RemovePasswordAsync(User.Identity.GetUserId());
}
else
{
result = await UserManager.RemoveLoginAsync(User.Identity.GetUserId(),
new UserLoginInfo(model.LoginProvider, model.ProviderKey));
}
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
// GET account/ExternalLogin
[OverrideAuthentication]
[HostAuthentication(DefaultAuthenticationTypes.ExternalCookie)]
[AllowAnonymous]
[Route("externallogin", Name = "ExternalLoginAPI")]
public async Task<IHttpActionResult> GetExternalLogin(string provider, string error = null)
{
if (error != null)
{
return Redirect(Url.Content("~/") + "#error=" + Uri.EscapeDataString(error));
}
if (!User.Identity.IsAuthenticated)
{
return new ChallengeResult(provider, this);
}
ExternalLoginData externalLogin = ExternalLoginData.FromIdentity(User.Identity as ClaimsIdentity);
if (externalLogin == null)
{
return InternalServerError();
}
if (externalLogin.LoginProvider != provider)
{
Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);
return new ChallengeResult(provider, this);
}
ApplicationUser user = await UserManager.FindAsync(new UserLoginInfo(externalLogin.LoginProvider,
externalLogin.ProviderKey));
bool hasRegistered = user != null;
if (hasRegistered)
{
Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);
ClaimsIdentity oAuthIdentity = await user.GenerateUserIdentityAsync(UserManager,
OAuthDefaults.AuthenticationType);
ClaimsIdentity cookieIdentity = await user.GenerateUserIdentityAsync(UserManager,
CookieAuthenticationDefaults.AuthenticationType);
AuthenticationProperties properties = ApplicationOAuthProvider.CreateProperties(user.UserName);
Authentication.SignIn(properties, oAuthIdentity, cookieIdentity);
}
else
{
IEnumerable<Claim> claims = externalLogin.GetClaims();
ClaimsIdentity identity = new ClaimsIdentity(claims, OAuthDefaults.AuthenticationType);
Authentication.SignIn(identity);
}
return Ok();
}
// GET account/ExternalLogins?returnUrl=%2F&generateState=true
[AllowAnonymous]
[Route("externallogins")]
public IEnumerable<ExternalLoginViewModel> GetExternalLogins(string returnUrl, bool generateState = false)
{
IEnumerable<AuthenticationDescription> descriptions = Authentication.GetExternalAuthenticationTypes();
List<ExternalLoginViewModel> logins = new List<ExternalLoginViewModel>();
string state;
if (generateState)
{
const int strengthInBits = 256;
state = RandomOAuthStateGenerator.Generate(strengthInBits);
}
else
{
state = null;
}
foreach (AuthenticationDescription description in descriptions)
{
ExternalLoginViewModel login = new ExternalLoginViewModel
{
Name = description.Caption,
Url = Url.Route("ExternalLogin", new
{
provider = description.AuthenticationType,
response_type = "token",
client_id = Startup.PublicClientId,
redirect_uri = new Uri(Request.RequestUri, returnUrl).AbsoluteUri,
state = state
}),
State = state
};
logins.Add(login);
}
return logins;
}
// POST account/Register
[AllowAnonymous]
[Route("register")]
public async Task<IHttpActionResult> Register(RegisterBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var user = new ApplicationUser() { UserName = model.Email, Email = model.Email };
IdentityResult result = await UserManager.CreateAsync(user, model.Password);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
// POST account/RegisterExternal
[OverrideAuthentication]
[HostAuthentication(DefaultAuthenticationTypes.ExternalBearer)]
[Route("registerexternal")]
public async Task<IHttpActionResult> RegisterExternal(RegisterExternalBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var info = await Authentication.GetExternalLoginInfoAsync();
if (info == null)
{
return InternalServerError();
}
var user = new ApplicationUser() { UserName = model.Email, Email = model.Email };
IdentityResult result = await UserManager.CreateAsync(user);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
result = await UserManager.AddLoginAsync(user.Id, info.Login);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
protected override void Dispose(bool disposing)
{
if (disposing && _userManager != null)
{
_userManager.Dispose();
_userManager = null;
}
base.Dispose(disposing);
}
#region Helpers
private IAuthenticationManager Authentication
{
get { return Request.GetOwinContext().Authentication; }
}
private IHttpActionResult GetErrorResult(IdentityResult result)
{
if (result == null)
{
return InternalServerError();
}
if (!result.Succeeded)
{
if (result.Errors != null)
{
foreach (string error in result.Errors)
{
ModelState.AddModelError("", error);
}
}
if (ModelState.IsValid)
{
// No ModelState errors are available to send, so just return an empty BadRequest.
return BadRequest();
}
return BadRequest(ModelState);
}
return null;
}
private class ExternalLoginData
{
public string LoginProvider { get; set; }
public string ProviderKey { get; set; }
public string UserName { get; set; }
public IList<Claim> GetClaims()
{
IList<Claim> claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, ProviderKey, null, LoginProvider));
if (UserName != null)
{
claims.Add(new Claim(ClaimTypes.Name, UserName, null, LoginProvider));
}
return claims;
}
public static ExternalLoginData FromIdentity(ClaimsIdentity identity)
{
if (identity == null)
{
return null;
}
Claim providerKeyClaim = identity.FindFirst(ClaimTypes.NameIdentifier);
if (providerKeyClaim == null || String.IsNullOrEmpty(providerKeyClaim.Issuer)
|| String.IsNullOrEmpty(providerKeyClaim.Value))
{
return null;
}
if (providerKeyClaim.Issuer == ClaimsIdentity.DefaultIssuer)
{
return null;
}
return new ExternalLoginData
{
LoginProvider = providerKeyClaim.Issuer,
ProviderKey = providerKeyClaim.Value,
UserName = identity.FindFirstValue(ClaimTypes.Name)
};
}
}
private static class RandomOAuthStateGenerator
{
private static RandomNumberGenerator _random = new RNGCryptoServiceProvider();
public static string Generate(int strengthInBits)
{
const int bitsPerByte = 8;
if (strengthInBits % bitsPerByte != 0)
{
throw new ArgumentException("strengthInBits must be evenly divisible by 8.", "strengthInBits");
}
int strengthInBytes = strengthInBits / bitsPerByte;
byte[] data = new byte[strengthInBytes];
_random.GetBytes(data);
return HttpServerUtility.UrlTokenEncode(data);
}
}
#endregion
}
}
ステップ5.サーバーがOAuthトークンを生成および検証できるようにApplicationOAuthProviderを作成する必要もあります。これはWebAPIサンプルプロジェクトで提供されます。これは私のファイルのバージョンです。
ApplicationOAuthProvider.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OAuth;
using Butler.Models;
using Schloss.AspNet.Identity.Neo4j;
namespace Butler.Providers
{
public class ApplicationOAuthProvider : OAuthAuthorizationServerProvider
{
private readonly string _publicClientId;
public ApplicationOAuthProvider(string publicClientId)
{
if (publicClientId == null)
{
throw new ArgumentNullException("publicClientId");
}
_publicClientId = publicClientId;
}
public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
var userManager = context.OwinContext.GetUserManager<ApplicationUserManager>();
ApplicationUser user = await userManager.FindAsync(context.UserName, context.Password);
if (user == null)
{
context.SetError("invalid_grant", "The user name or password is incorrect.");
return;
}
ClaimsIdentity oAuthIdentity = await user.GenerateUserIdentityAsync(userManager,
OAuthDefaults.AuthenticationType);
ClaimsIdentity cookiesIdentity = await user.GenerateUserIdentityAsync(userManager,
CookieAuthenticationDefaults.AuthenticationType);
AuthenticationProperties properties = CreateProperties(user.UserName);
AuthenticationTicket ticket = new AuthenticationTicket(oAuthIdentity, properties);
context.Validated(ticket);
context.Request.Context.Authentication.SignIn(cookiesIdentity);
}
public override Task TokenEndpoint(OAuthTokenEndpointContext context)
{
foreach (KeyValuePair<string, string> property in context.Properties.Dictionary)
{
context.AdditionalResponseParameters.Add(property.Key, property.Value);
}
return Task.FromResult<object>(null);
}
public override Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
// Resource owner password credentials does not provide a client ID.
if (context.ClientId == null)
{
context.Validated();
}
return Task.FromResult<object>(null);
}
public override Task ValidateClientRedirectUri(OAuthValidateClientRedirectUriContext context)
{
if (context.ClientId == _publicClientId)
{
//Uri expectedRootUri = new Uri(context.Request.Uri, "/");
//if (expectedRootUri.AbsoluteUri == context.RedirectUri)
//{
context.Validated();
//}
}
return Task.FromResult<object>(null);
}
public static AuthenticationProperties CreateProperties(string userName)
{
IDictionary<string, string> data = new Dictionary<string, string>
{
{ "userName", userName }
};
return new AuthenticationProperties(data);
}
}
}
また、ChallengeResultも含まれます。これは、アプリケーションのWeb APIアームが、ユーザーを認証するために外部ログインプロバイダーによって提供されるチャレンジを処理するために使用します。
ChallengeResult.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Http;
namespace Butler.Results
{
public class ChallengeResult : IHttpActionResult
{
public ChallengeResult(string loginProvider, ApiController controller)
{
LoginProvider = loginProvider;
Request = controller.Request;
}
public string LoginProvider { get; set; }
public HttpRequestMessage Request { get; set; }
public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
{
Request.GetOwinContext().Authentication.Challenge(LoginProvider);
HttpResponseMessage response = new HttpResponseMessage(HttpStatusCode.Unauthorized);
response.RequestMessage = Request;
return Task.FromResult(response);
}
}
}
このコードセットを使用すると、HTTP GETおよびHTTP POST AccountControllerのAPIバージョンのルートを使用してユーザーを登録し、ユーザー名とパスワードを使用してログインしてBearerトークンを取得し、外部ログインの追加/削除、外部ログインの管理、そして最も重要な問題として、アプリケーションのOAuthベアラートークンと引き換えに外部ログイントークンを渡すことで認証します。
次の一連の記事を読んで、それがあなたの目標をカバーしているかどうかを確認してください。
ASP.NET Web API 2、Owin、およびIdentityを使用したトークンベース認証Taiseer Joudeh(SOに関する質問にも頻繁に回答します)
記事では、OWINを使用したトークンベースの認証サービスの作成について説明し、外部ログイン(FacebookやGoogle+など)の使用について説明しています。この例は、主にWebサービスのコンシューマーとしてのWebアプリケーションを中心にしていますが、モバイルアプリケーションでも動作するはずです。記事にはGitHubプロジェクトが関連付けられており、非常に活発なコメントセクションがあり、そこでは質問に答えることはほとんどありません。
これがあなたの目標につながることを願っています。
[〜#〜] yes [〜#〜]同じデータベースに関連付けられた2つの個別のプロジェクトを持ち、単純にMVC/Web Forms WebサイトプロジェクトはすべてのCookie認証を使用してから、すべてトークン認証である別のWeb APIプロジェクトを作成します。
ソースコードの例を使用した長い回答では、基本的に行ったことは、2つの別々のプロジェクトを1つのプロジェクトに結合して、冗長なモデルコードとコントローラーコードを回避することです。私の場合、これは私にとってより意味がありました。ただし、個人の好みとプロジェクトのニーズ次第で、2つの個別のプロジェクト(1つのWebサイトと1つのWeb APIエンドポイント)を維持するか、それらを結合するかを決定する必要があります。
ASP.NETは非常に柔軟で、ミドルウェアとしてプラグアンドプレイするように設計されており、2つの別個のプロジェクトのコードで意図したとおりにプロジェクトが存在し、機能することを証明できます。