Angular 17 & .NET 8 - Line OAuth2 Sign-on
文章目錄
一、 事前準備
- 前往 LINE Developers Console 建立 LINE Login channel,取得 Channel ID 與 Channel Secret Key。
- 設定 callback URL,以接收 LINE 回傳的結果。
- 申請取得使用者電子郵件的使用權限。
二、導向 LINE 登入頁面
- 使用者按下按鈕,取得 LINE 登入頁面路徑,導向至 LINE 登入頁面。
redirectToSignIn() {
this._http.get(this._env.APIEndpoint.OAuthLine, { responseType: 'text' })
.subscribe(res => location.href = res);
}
組合參數回傳 LINE 登入頁面路徑:
- 亂數產生參數
state
與nonce
,以防止 CSRF 攻擊與 Replay 攻擊。 - 將
state
與nonce
儲存在 cookies 中以便後續驗證。 - 回傳含有參數的 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
:用來接收使用者是否同意授權並成功登入的路徑。- 必須在LINE Developers Console 中的 Callback URL 設定
redirect_uri
。
- 必須在LINE Developers Console 中的 Callback URL 設定
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 登入回應處理
- LINE 將登入結果回傳至端點
redirect_uri
,並接收參數。- 使用者同意授權並登入成功,則取得
code
、state
參數。 - 使用者拒絕授權或登入失敗,則取得
error
參數。
- 使用者同意授權並登入成功,則取得
- 驗證使用者同意授權並登入成功,則請求後端進行處理。
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登入失敗。");
}
}
後端將進行以下處理:
- 從 Cookies 取出
state
,驗證是否與接收的state
一致。 - 透過
state
取得 Token。 - 驗證
idToken
取得 LINE 使用者資訊,並從 Cookies 取出nonce
,驗證是否與使用者資訊中的nonce
一致。 - 若使用者已存在則登入,否則註冊使用者。
[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);
}
參考資料
相關文章