Angular 17 & .NET 8 - Line OAuth2 Sign-on

文章目錄

一、 事前準備

  1. 前往 LINE Developers Console 建立 LINE Login channel,取得 Channel ID 與 Channel Secret Key。
  2. 設定 callback URL,以接收 LINE 回傳的結果。
  3. 申請取得使用者電子郵件的使用權限。

二、導向 LINE 登入頁面

  1. 使用者按下按鈕,取得 LINE 登入頁面路徑,導向至 LINE 登入頁面。
redirectToSignIn() {
this._http.get(this._env.APIEndpoint.OAuthLine, { responseType: 'text' })
    .subscribe(res => location.href = res);
}

組合參數回傳 LINE 登入頁面路徑:

  1. 亂數產生參數 statenonce,以防止 CSRF 攻擊Replay 攻擊
  2. statenonce 儲存在 cookies 中以便後續驗證。
  3. 回傳含有參數的 LINE 登入頁面路徑。
[HttpGet]
public string GetSignInPage()
{
    var state = _util.GetRandomString();
    var nonce = Guid.NewGuid().ToString();
    _util.SetCookies(Response, "line_state", state);
    _util.SetCookies(Response, "line_nonce", nonce);
    return _service.GetLoginInUrl(state, nonce);
}

產生亂數

生成一個 8 字節長的隨機值,將其轉換為 UTF-8 字串,再將這個字串進一步轉換為 URL 安全的 Base64 編碼字串。

public string GetRandomString()
{
    byte[] randomValues = new byte[8];
    using (RandomNumberGenerator rng = RandomNumberGenerator.Create())
    {
        rng.GetBytes(randomValues);
    }

    string utf8String = Encoding.UTF8.GetString(randomValues);

    string base64Encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(utf8String))
        .Replace('+', '-')
        .Replace('/', '_')
        .TrimEnd('=');

    return base64Encoded;
}

設置 Cookies

  • HttpOnly = true:Cookie 只能通過 HTTP 請求訪問,而不能通過 JavaScript 訪問,有助於防止 XSS 攻擊
  • Secure = true:Cookie 只能通過 HTTPS 連接傳輸,這可以增強 Cookie 的安全性。
  • SameSite = SameSiteMode.Strict:只能在相同網域的請求中發送 Cookie,有助於防止 CSRF 攻擊
public void SetCookies(HttpResponse res, string key, string value)
{
    if (_env.IsProduction())
    {
        res.Cookies.Append(key, value, new CookieOptions
        {
            HttpOnly = true,
            Secure = true,
            SameSite = SameSiteMode.Strict
        });
    }
    else
    {
        res.Cookies.Append(key, value, new CookieOptions
        {
            HttpOnly = true
        });
    }
}

產生 LINE 登入頁面路徑

  • client_id:取得的 Channel ID。
  • redirect_uri:用來接收使用者是否同意授權並成功登入的路徑。
  • scope:想請求使用者授予的權限。查閱可請求的範圍scope
public string GetLoginInUrl(string state, string nonce)
{
    var queryParams = new Dictionary<string, string>
    {
        {"client_id", _clientId},
        {"redirect_uri", _redirectUri},
        {"response_type", "code"},
        {"scope", _scope},
        {"state", state},
        {"nonce", nonce}
    };

    var queryString = string.Join("&", queryParams.Select(kvp => $"{kvp.Key}={kvp.Value}"));
    return $"{_authAPI}?{queryString}";
}

三、LINE 登入回應處理

  1. LINE 將登入結果回傳至端點 redirect_uri,並接收參數。
    • 使用者同意授權並登入成功,則取得 codestate 參數。
    • 使用者拒絕授權或登入失敗,則取得 error 參數。
  2. 驗證使用者同意授權並登入成功,則請求後端進行處理。
handleSignInCallback() { 
    const params = new URLSearchParams(location.search);
    if(params.size) {
        const code = params.get("code");
        const state = params.get("state");
        const error = params.get("error");

        if(error != "access_denied" && code && state) {
            const data = new FormData();
            data.append('code', code);
            data.append('state', state);
            this._http.post<AuthenticateUser>(this._env.APIEndpoint.OAuthLine, data).subscribe(user => {
                localStorage.setItem('user', JSON.stringify(res));
                this._router.navigateByUrl('/');
            });
        } else console.error("Line登入失敗。");
    }
}

後端將進行以下處理:

  1. 從 Cookies 取出 state,驗證是否與接收的 state 一致。
  2. 透過 state 取得 Token
  3. 驗證 idToken 取得 LINE 使用者資訊,並從 Cookies 取出 nonce,驗證是否與使用者資訊中的 nonce 一致。
  4. 若使用者已存在則登入,否則註冊使用者。
[HttpPost]
public async Task<IActionResult> GetAuthenticateUser([FromForm] string code, [FromForm] string state)
{
    try
    {
        var storageState = Request.Cookies["line_state"];
        var storageNonce = Request.Cookies["line_nonce"];
        if (code.IsNullOrEmpty() || state.IsNullOrEmpty() || state != storageState)
            throw new Exception();

        Response.Cookies.Delete("line_state");
        Response.Cookies.Delete("line_nonce");

        var res = await _service.ExchangeAuthorizationCodeAsync(code!);
        var idToken = res?.IdToken ?? throw new Exception();

        var profile = await _service.GetUserProfile(idToken) ?? throw new Exception();
        var email = profile.Email ?? throw new Exception();
        var nonce = profile.Nonce ?? throw new Exception();
        if (nonce != storageNonce) throw new Exception();

        // 以信箱從資料庫取得使用者
        var user = await _userService.GetByEmailAsync(email);
        if (user == null)
        {
            // 若使用者不存在則建立新使用者(註冊)
            AppUser newUser = new();
            await _userService.InsetAsync(newUser);
            return Ok(newUser);
        }
        // 使用者已存在(登入)
        return Ok(user);
    }
    catch (Exception ex)
    {
        return StatusCode(StatusCodes.Status500InternalServerError);
    }
}

取得 Token

透過接收的 code 請求 Token。

  • client_id:取得的 Channel ID。
  • client_secret:取得的 Channel Secret Key。
public async Task<TokenResponse?> ExchangeAuthorizationCodeAsync(string code)
{
    var requestBody = new Dictionary<string, string>
    {
        { "client_id", _clientId },
        { "client_secret", _clientSecret },
        { "code", code },
        { "redirect_uri",  _redirectUri },
        { "grant_type", "authorization_code" }
    };
    var content = new FormUrlEncodedContent(requestBody);

    using HttpClient client = _httpClient.CreateClient();
    var response = await client.PostAsync(_tokenAPI, content);

    var resContent = await response.Content.ReadAsStringAsync();

    if (!response.IsSuccessStatusCode)
    {
        var err = JsonSerializer.Deserialize<ErrorResponse>(resContent);
        throw new Exception($"{err?.Error}:${err?.ErrorDescription}");
    }

    return JsonSerializer.Deserialize<TokenResponse>(resContent);
}

取得 LINE 使用者資訊

驗證 idToken 請求 LINE 使用者資訊。

public async Task<LineUser?> GetUserProfile(string idToken)
{
    var requestBody = new Dictionary<string, string>
    {
        { "client_id", _clientId },
        { "id_token", idToken }
    };
    var content = new FormUrlEncodedContent(requestBody);

    using HttpClient client = _httpClient.CreateClient();
    var response = await client.PostAsync(_profileAPI, content);
    var resContent = await response.Content.ReadAsStringAsync();

    if (!response.IsSuccessStatusCode)
    {
        var err = JsonSerializer.Deserialize<ErrorResponse>(resContent);
        throw new Exception($"{err?.Error}:${err?.ErrorDescription}");
    }

    return JsonSerializer.Deserialize<LineUser>(resContent);
}

參考資料