[Line Login API] Line Login 並取得用戶基本資料,使用 ASP.net MVC 5

Line Login using ASP.net MVC 5

前言

Line Login API目前沒有提供Javascript SDK,只有提供單純的HTTP Web API讓各種程式語言發出Request存取

雖然是可以自己完全打造純Javascript的登入流程,但是Line Login會需要搭配Channel secret (Client secret) 應用程式私鑰

這種東西就不方便直接曝露在前端Javascript了

我看網路上很多教學文章的Line Login都是Server端在處理作業,導頁來導頁去,沒有一個popup window範例,所以乾脆自己實作

而且我工作上也碰過Line登入頁不給你嵌入iframe,他們的網站有設定 X-Frame-Options 回應標頭

此時popup window的登入方式就是一個解決方案

使用popup window來實作第三方登入另一個好處是網頁不會導頁來導頁去,如果你的畫面裡剛好有表單、手風摺琴、頁籤這些UI元件,就是很好的組合

因為使用者填寫到一半的表單會因為導頁後,畫面上填寫的資料都被清空

前置作業

Line Developers Console網址:https://developers.line.biz/console/

Line登入應用程式的申請配置我懶得擷圖,請參考其他網友文章:[筆記]Line Login 使用MVC C# | 遇見零壹魔王- 點部落

要存取用戶email權限的話,應用程式設定請參考董大偉老師的文章:使用C#開發Linebot(30) – 使用LINE Login時取得用戶email

目前Line Login官方API文件量不多,難易度也還好,本文章大部份採用官方建議撰寫程式(資安上):Integrating LINE Login with your web app

2019-10-22 補充

不同Provider的Line Login Channel  即使前端用戶同一個人登入,取得的UserID會不一樣

但相同 Provider 底下的Line Login channel 前端用戶同一個人登入,則取得的UserID會相同,這特性和 FB Login App有點類似,要留意

撰寫程式碼

先看Controller,說明在註解裡

using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Web;
using System.Web.Mvc;

namespace Web_GooglePeopleAPITest.Controllers
{
    public class HomeController : Controller
    {
        //以下自行修改從WebConfig讀取
        string redirect_uri = "https://localhost:44385/Home/AfterLineLogin";
        string client_id = "165333****";
        string client_secret = "8f38edb*******f36517430075d1";

        /// <summary>
        /// 主要Demo畫面
        /// </summary>
        /// <returns></returns>
        public ActionResult DemoView()
        {
            return View();

        }


        /// <summary>
        /// 產生新的LineLoginUrl
        /// </summary>
        /// <returns></returns>
        public ActionResult GetLineLoginUrl()
        {
            if (Request.IsAjaxRequest()==false)
            {
                return Content("");
            }
            //只讓本機Ajax讀取LineLoginUrl

            //state使用隨機字串比較安全
            //每次Ajax Request都產生不同的state字串,避免駭客拿固定的state字串將網址掛載自己的釣魚網站獲取用戶的Line個資授權(CSRF攻擊)
            string state = Guid.NewGuid().ToString();
            TempData["state"] = state;//利用TempData被取出資料後即消失的特性,來防禦CSRF攻擊
            //如果是ASP.net Form,就改成放入Session或Cookie,之後取出資料時再把Session或Cookie設為null刪除資料
            string LineLoginUrl =
             $@"https://access.line.me/oauth2/v2.1/authorize?response_type=code&client_id={client_id}&redirect_uri={redirect_uri}&state={state}&scope={HttpUtility.UrlEncode("openid profile email")}";
            //scope給openid是程式為了抓id_token用,設email則為了id_token的Payload裡才會有用戶的email資訊
            return Content(LineLoginUrl);

        }
        
        /// <summary>
        /// 使用者在Line網頁登入後的處理,接收Line傳遞過來的參數
        /// </summary>
        /// <param name="state"></param>
        /// <param name="code"></param>
        /// <param name="error"></param>
        /// <param name="error_description"></param>
        /// <returns></returns>
        public ActionResult AfterLineLogin(string state, string code,string error,string error_description)
        {
            if (!string.IsNullOrEmpty(error))
            {//用戶沒授權你的LineApp
                ViewBag.error = error;
                ViewBag.error_description = error_description;
                return View();
            }
            
            if (TempData["state"] == null)
            {//可能使用者停留Line登入頁面太久

                return Content("頁面逾期");

            }
            
            if (Convert.ToString(TempData["state"]) != state)
            {//使用者原先Request QueryString的TempData["state"]和Line導頁回來夾帶的state Querystring不一樣,可能是parameter tampering或CSRF攻擊

                return Content("state驗證失敗");

            } 

            if (Convert.ToString(TempData["state"])==state)
            {//state字串驗證通過
                  
                //取得id_token和access_token:https://developers.line.biz/en/docs/line-login/web/integrate-line-login/#spy-getting-an-access-token
                string issue_token_url = "https://api.line.me/oauth2/v2.1/token"; 
                HttpWebRequest request = (HttpWebRequest)WebRequest.Create(issue_token_url);
                request.Method = "POST";
                request.ContentType = "application/x-www-form-urlencoded"; 
                //必須透過ParseQueryString()來建立NameValueCollection物件,之後.ToString()才能轉換成queryString
                NameValueCollection postParams = HttpUtility.ParseQueryString(string.Empty); 
                postParams.Add("grant_type", "authorization_code");
                postParams.Add("code", code);
                postParams.Add("redirect_uri", this.redirect_uri);
                postParams.Add("client_id", this.client_id);
                postParams.Add("client_secret", this.client_secret);

                //要發送的字串轉為byte[] 
                byte[] byteArray = Encoding.UTF8.GetBytes(postParams.ToString());
                using (Stream reqStream = request.GetRequestStream())
                {
                    reqStream.Write(byteArray, 0, byteArray.Length);
                }//end using

                //API回傳的字串
                string responseStr = "";
                //發出Request
                using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
                {
                    using (StreamReader sr = new StreamReader(response.GetResponseStream(), Encoding.UTF8))
                    {
                        responseStr = sr.ReadToEnd();
                    }//end using  
                }
           

                LineLoginToken tokenObj = JsonConvert.DeserializeObject<LineLoginToken>(responseStr);
                string id_token = tokenObj.id_token;
                
                //方案總管>參考>右鍵>管理Nuget套件 搜尋 System.IdentityModel.Tokens.Jwt 來安裝
                var jst = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(id_token);
                LineUserProfile user = new LineUserProfile();
                //↓自行決定要從id_token的Payload中抓什麼user資料
                user.userId = jst.Payload.Sub;
                user.displayName = jst.Payload["name"].ToString();
                user.pictureUrl = jst.Payload["picture"].ToString();
                if (jst.Payload.ContainsKey("email") && !string.IsNullOrEmpty(Convert.ToString(jst.Payload["email"])))
                {//有包含email,使用者有授權email個資存取,並且用戶的email有值
                    user.email = jst.Payload["email"].ToString();
                }


                string access_token = tokenObj.access_token;
                ViewBag.access_token = access_token;
                #region 接下來是為了抓用戶的statusMessage狀態消息,如果你不想要可以省略不發出下面的Request

                //Social API v2.1 Getting user profiles
                //https://developers.line.biz/en/docs/social-api/getting-user-profiles/
                //取回User Profile
                string profile_url = "https://api.line.me/v2/profile";


                HttpWebRequest req = (HttpWebRequest)WebRequest.Create(profile_url);
                req.Headers.Add("Authorization", "Bearer " + access_token);
                req.Method = "GET";
                //API回傳的字串
                string resStr = "";
                //發出Request
                using (HttpWebResponse res = (HttpWebResponse)req.GetResponse())
                {
                    using (StreamReader sr = new StreamReader(res.GetResponseStream(), Encoding.UTF8))
                    {
                        resStr = sr.ReadToEnd();
                    }//end using  
                }
               
                  

                LineUserProfile userProfile = JsonConvert.DeserializeObject<LineUserProfile>(resStr);
                user.statusMessage = userProfile.statusMessage;//補上狀態訊息

                #endregion

                ViewBag.user = JsonConvert.SerializeObject(user,new JsonSerializerSettings 
                { 
                 ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
                 Formatting = Formatting.Indented
                });
                

            }//end if 
            

            return View();
        }


        /// <summary>
        /// 徹銷Line Login,目前感覺不出差別在哪= =a,等待API改版
        /// </summary>
        /// <returns></returns>
        [HttpPost]
        public ActionResult RevokeLineLoginUrl(string access_token)
        {
            HttpWebRequest req = (HttpWebRequest)WebRequest.Create("https://api.line.me/oauth2/v2.1/revoke");
            req.Method = "POST"; 
            req.ContentType = "application/x-www-form-urlencoded";
            //必須透過ParseQueryString()來建立NameValueCollection物件,之後.ToString()才能轉換成queryString
            NameValueCollection postParams = HttpUtility.ParseQueryString(string.Empty);
            postParams.Add("access_token", access_token);
            postParams.Add("client_id", this.client_id);
            postParams.Add("client_secret", this.client_secret);


            //要發送的字串轉為byte[] 
            byte[] byteArray = Encoding.UTF8.GetBytes(postParams.ToString());
            using (Stream reqStream = req.GetRequestStream())
            {
                reqStream.Write(byteArray, 0, byteArray.Length);
            }//end using

            //API回傳的字串
            string responseStr = "";
            //發出Request
            using (HttpWebResponse response = (HttpWebResponse)req.GetResponse())
            {
                using (StreamReader sr = new StreamReader(response.GetResponseStream(), Encoding.UTF8))
                {
                    responseStr = sr.ReadToEnd();
                }//end using  
            }


            return Content(responseStr);
        }
    }
    public class LineLoginToken
    {
        public string access_token { get; set; }
        public int expires_in { get; set; }
        public string id_token { get; set; }
        public string refresh_token { get; set; }
        public string scope { get; set; }
        public string token_type { get; set; }
    }

    public class LineUserProfile
    {
        public string userId { get; set; }
        public string displayName { get; set; }
        public string pictureUrl { get; set; }
        public string statusMessage { get; set; }
        public string email { get; set; }
    }
}
 

要傳給Line網站的參數說明↓

↓DemoView.cshtml

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Demo Line Login</title>
</head>
<body>
    <a href="javascript:openPopupWindow()">
        點我Line Login
    </a>
    |
    <a href="javascript:revokeLineApp();">
        點我撤銷Line的授權
    </a>
    <hr />
    <div id="result"></div>


    <!--引用jQuery-->
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
    <script type="text/javascript">
        let LineLoginUrl = "@Url.Action("GetLineLoginUrl","Home")";
        let RevokeLineLoginUrl = "@Url.Action("RevokeLineLoginUrl","Home")";
    </script>
    <script type="text/javascript"> 
        function openPopupWindow() {
            $("#result").html("");//清空顯示結果
            //另開popup window前,每次都取得新的LineLoginUrl(每次Url的state參數都不一樣)
            $.ajax({
                url: LineLoginUrl,
                method: "get",
                success: function (url) {
                    window.open(url, "_blank", "width=800px,height=600px");
                }, error: function (xhr) {
                    console.log(xhr);
                }
            }); 
        }
        var access_token = "";
        function revokeLineApp() {
            if (access_token === "")
            {
                $("#result").html("請先登入Line");
            } else {
                $.ajax({
                    url: RevokeLineLoginUrl,
                    method: "post",
                    data: { access_token, access_token }, 
                    success: function (result) {
                        $("#result").html("已徹銷Line的授權<hr/>" + result);

                    }, error: function (xhr) {
                        console.log(xhr);
                    }
                });


            }

        }
    </script>
</body>
</html>

↓AfterLineLogin.cshtml

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>AfterLineLogin</title>
</head>
<body>
    @if (!string.IsNullOrEmpty(Convert.ToString(ViewBag.error)))
    { 
      <h1>@ViewBag.error</h1>
      <h2>@ViewBag.error_description</h2>
    }


    @if (!string.IsNullOrEmpty(Convert.ToString(ViewBag.user)))
    {
        <script type="text/javascript">
            window.onload = function () {
                let userObj  = @Html.Raw(Convert.ToString(ViewBag.user));
                window.opener.document.getElementById("result").innerHTML = JSON.stringify(userObj); 


                window.opener.access_token = "@Html.Raw(ViewBag.access_token)";
                window.close();
        }
        </script>
    }

</body>
</html>

程式執行結果

其實

Getting an access token (POST https://api.line.me/oauth2/v2.1/token):https://developers.line.biz/en/docs/line-login/web/integrate-line-login/#spy-getting-an-access-token

Social API v2.1 Get user profile:https://developers.line.biz/en/reference/social-api/#get-user-profile

↑ 兩邊同樣都可以取得userID(Line系統識別用戶的ID,不是讓別人加好友的那個ID)、name、pictureUrl

兩邊回傳的用戶個資差異在POST https://api.line.me/oauth2/v2.1/token 可以額外取得用戶email,Social API v2.1 Get user profile 則可以額外取得用戶的 "statusMessage" 狀態訊息

下圖是POST https://api.line.me/oauth2/v2.1/token 的回應內容

下圖是 Social API v2.1 Get user profile:https://developers.line.biz/en/reference/social-api/#get-user-profile 的回應內容

 

 

一些眉眉角角

Logging out users 請參考:https://developers.line.biz/en/docs/social-api/logging-out-users ,不過有沒有讓使用者登出,目前我感覺不出差異在哪,等待Line API改版看看

Line登入網址可以多加一個prompt=consent參數,例如:https://access.line.me/oauth2/v2.1/authorize?response_type=code&prompt=consent.....(略

↑ 如此使用者登入Line的時候,就會永遠顯示同意畫面

但是以上,我已試過就算你把使用者Logout,而且Line登入網址多加prompt=consent參數

如果使用者曾經授權過email讓你存取,他下次重新登入仍然不能變更是否授權email權限給你

↓目前官網我只找到使用者唯有在他修改過自己email後,他才能重新選擇是否要授權email給你的App存取

最終開發完成,當你的網站丟到PRD正式環境後,記得順便Published你的Line應用程式,如此redirect_uri裡設定的正式機DomainName才能生效,如果你忘記這回事也沒關係

因為當你遇到應用程式仍在Developing錯誤訊息時,就知道要把Line應用程式Published了XD

 

結語

Line API目前看來看去還無法取得用戶的電話、生日

個人覺得可以取得用戶個資數量的社群API:Google > Facebook > Line

 

猜你也感興趣的文章

一目瞭然!Line vs Facebook vs Goolge,比較各自的API可以取得的用戶個資