Contents

LINE登入-使用.NET Core OAuth

LINE登入-使用.NET Core OAuth

這篇算是同場加映,因為LINE Login 微軟官方並沒有提供現成的OAuth.LINE可用,所以我研究了一下替代方案OAuth,這套件似乎不用額外安裝,因為中間也採了很多雷,覺得值得紀錄一下,順便分享出來,看是否有其他人適用,當然也不僅適用於LINE,只要是OAuth流程都可以使用,這方法主要是使用Microsoft.AspNetCore.Authentication.OAuth自己客製化。

LINE 應用程式服務建立請參考 LINE登入-手動建立登入 OAuth 2

OAuth 2.0,其工作方式如下:

  1. 應用程式(OAuth套件)將使用者導向第三方驗證連結AuthorizationEndpoint,就是那個登入視窗,使用者輸入帳號和密碼,並授權應用程式可以訪問哪些請求的資源,會不會出現請求資源的視窗,是透過設定Scope而定,並不一定會有。
  2. OAuth會攔截授權結果,若成功會取得code,這相當於使用者同意書的概念
  3. 接著應用程式會將授權代碼code,連同ClientIdClientSecretCallbackPath,發送到TokenEndpoint,這是一個交換的過程,把授權書(code),還有API服務的帳號給驗證方,交換訪問令牌AccessToken
  4. 驗證方的授權服務器返回AccessToken。
  5. 若成功取得AccessToken,應用程式會把AccessToken附在UserInformationEndpoint連結中,告知要取得個人資料。
  6. 授權服務器返回所請求的個人資料。

使用Authentication.OAuth客製化LINE登入

  1. 要先給OAuth服務一個名稱,因為可能同時存在很多個第三方驗證,這邊的名稱要與authenticationScheme對應

    1
    
    AddOAuth("Line");
    
    /static/LINE_NETCore_OAuth2_384721222610449aaec7ced667ae5b95/2020-09-23_15-33-51.png
  2. 設置基本OAuth參數,如果對OAuth已經有初步了解,大概就知道這些參數的意義,多數是不能省略的,要特別留意CallbackPath這項(必填),他回傳是AuthorizationEndpoint驗證的結果code,這路徑不需要實際存在,只要與Line Developer API的回傳路徑對應即可,如果這邊填寫了實際回傳路徑,則會告訴你State驗證值被改變了,反而不能登入,程序中他會將Token轉換為TokenResponse

    另外LINE Profile 並不提供Email,所以options.Scope.Add("email")也可以省略

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    options.ClientId = "{應用程式編號}";
    options.ClientSecret = "{應用程式密鑰}";
    options.AuthorizationEndpoint = "https://access.line.me/oauth2/v2.1/authorize";
    options.TokenEndpoint = "https://api.line.me/oauth2/v2.1/token";
    options.UserInformationEndpoint = "https://api.line.me/v2/profile";
    
    options.CallbackPath = new PathString("/signin-line");
    
    options.Scope.Add("profile");
    options.Scope.Add("openid");
    // 這邊即使設定了Email,https://api.line.me/v2/profile 也沒有提供Email欄位
    // options.Scope.Add("email");
    
    /static/LINE_NETCore_OAuth2_384721222610449aaec7ced667ae5b95/2020-09-23_15-43-36_(1).png
  3. 對應使用者資料,這邊要看API Response什麼資料

    1
    2
    
    options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "userId");
    options.ClaimActions.MapJsonKey(ClaimTypes.Name, "displayName", "string");
    
  4. 創造OAuth事件,並將資料對應到Claims

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    options.Events = new OAuthEvents
    {
        OnCreatingTicket = async context =>
        {
            var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
            request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    
            var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
            response.EnsureSuccessStatusCode();
    
            var json = await response.Content.ReadAsStringAsync();
            var user = JsonDocument.Parse(json);
    
            context.RunClaimActions(user.RootElement);
        },
        OnRemoteFailure = context =>
        {
            context.HandleResponse();
            context.Response.Redirect("/Home/Error?message=" + context.Failure.Message);
            return Task.FromResult(0);
        }
    };
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    // Event執行順序:
    // 1.創建Ticket之前觸發
    options.Events.OnCreatingTicket = context => Task.CompletedTask;
    // 2.創建Ticket失敗時觸發
    options.Events.OnRemoteFailure = context => Task.CompletedTask;
    // 3.Ticket接收完成後觸發
    options.Events.OnTicketReceived = context => Task.CompletedTask;
    // 4.Challenge時觸發,默認跳轉到OAuth Server
    options.Events.OnRedirectToAuthorizationEndpoint = context => context.Response.Redirect(context.RedirectUri);
    
  5. .NET Core版本,自訂OAuth驗證,需要啟動Authentication服務,如果少了這行,他不會執行Events程序

    1
    2
    
    app.UseAuthentication();
    app.UseAuthorization();
    

完整程式碼

Startup.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddCookie()
    .AddOAuth("Line", "Line", options =>
    {
        options.ClientId = "{應用程式編號}";
        options.ClientSecret = "{應用程式密鑰}";
        options.AuthorizationEndpoint = "https://access.line.me/oauth2/v2.1/authorize";
        options.TokenEndpoint = "https://api.line.me/oauth2/v2.1/token";
        options.UserInformationEndpoint = "https://api.line.me/v2/profile";
        options.CallbackPath = new PathString("/signin-line");

        options.Scope.Add("profile");
        options.Scope.Add("openid");
        // 也可以不設置,因為https://api.line.me/v2/profile 沒有提供Email欄位
        options.Scope.Add("email");

        options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "userId");
        options.ClaimActions.MapJsonKey(ClaimTypes.Name, "displayName", "string");

        options.Events = new OAuthEvents
        {
            OnCreatingTicket = async context =>
            {
                var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
                request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

                var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
                response.EnsureSuccessStatusCode();

                var json = await response.Content.ReadAsStringAsync();
                var user = JsonDocument.Parse(json);

                context.RunClaimActions(user.RootElement);
            },
            OnRemoteFailure = context =>
            {
                context.HandleResponse();
                context.Response.Redirect("/Home/Error?message=" + context.Failure.Message);
                return Task.FromResult(0);
            }
        };
    });

    services.AddControllersWithViews();

Controller

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class LoginController : Controller
{
    public IActionResult Index()
    {
        return View();
    }

    public IActionResult SignInLine(string provider, string returnUrl = null)
    {
        var redirectUrl = Url.Action("Callback", controller: "Login", values: new { returnUrl });
        return new ChallengeResult(provider, new AuthenticationProperties { RedirectUri = redirectUrl ?? "/" });
    }

    public IActionResult Callback()
    {
        var claimsIdentity = (ClaimsIdentity)HttpContext.User.Identity;
        // 略...後續流程可直接參考官方範例,或自訂
        return Ok();
    }
}

View

1
<a asp-action="SignInLine" asp-route-provider="Line" class="btn btn-primary" title="Log in your account">Line</a>
/static/LINE_NETCore_OAuth2_384721222610449aaec7ced667ae5b95/2020-09-23_11-43-43.png

參考

Nuget OAuth 套件

https://www.nuget.org/packages?q=owners%3Aaspnet-contrib+title%3AOAuth

ASP.NET Core 中保存外部提供者的其他宣告

https://docs.microsoft.com/zh-tw/aspnet/core/security/authentication/social/additional-claims?view=aspnetcore-3.1

ASP.NET Core 2.0 Authentication and Authorization System Demystified

https://digitalmccullough.com/posts/aspnetcore-auth-system-demystified.html

結論

我在CallbackPath吃了不少虧,反覆測了很久,才了解那個傳回的是codestate,寫完後我覺得如果回傳值都有自己想要的,那會很方便,但LINE Profile沒有提供Email,也在這邊花了不少時間,還意外從SourceCode,仿造出Line擴充…

題外話…一開始以為仿造Google的擴充是一個好的起始點,但研究一陣子後,發現最大困難點在於OAuth 使用的TokenResponse,不好擴充,所以與其從OAuth開始著手,不如像Twitter,由Microsoft.AspNetCore.Authentication開始還比較簡單,真的是入門到放棄…

/static/LINE_NETCore_OAuth2_384721222610449aaec7ced667ae5b95/2020-09-23_15-05-32.png