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

文章目錄

流程

Angular & .NET OAuth2 with Google

  1. 導向至 Google 登入頁面
  2. 接收來自 Google 的回應
  3. code 取得 Token
  4. access_token 取得使用者資訊
  5. 以使用者資訊註冊或登入

一、設定 Google OAuth 2.0

  1. 登入 Google API Console
  2. 「選取專案」或「新增專案」。
  3. 打開控制台左側選單選擇「API 和服務」。
  4. 設定「OAuth 同意畫面」,建立測試使用者。
  5. 選擇「憑證」,點擊「建立憑證」,然後選擇「OAuth 用戶端 ID」。
  6. 完成「建立 OAuth 用戶端 ID」,取得「用戶端 ID」與「用戶端密碼」。

二、導向 Google 登入頁面

oauth2導向至Google登入頁面

  1. 使用者按下按鈕,取得 Google 登入頁面路徑,導向至 Google 登入頁面。
redirectToSignIn() {
    this._http.get(this._env.APIEndpoint.OAuthGoogle, { responseType: 'text' })
    .subscribe(res => window.location.href = res);
}
  1. 設定參數,取得含有參數的 Google 登入頁面路徑。
    • 使用者登入 Google,並選擇是否同意授權應用程式(我們的系統)取得 scope 範圍內的個人資訊。
    • Google 將使用者是否授權登入的回應,回傳至 redirect_uri
    • 隨機產生 state 儲存至 HttpOnly Cookies 中,在 Google 回傳回應時驗證是否一致,以防止 CSRF 攻擊
[HttpGet]
public string GetSignInUrl()
{
    var oauth2Endpoint = "https://accounts.google.com/o/oauth2/v2/auth";
    var redirectUri = "http://localhost:4200/login";
    var scope = "https://www.googleapis.com/auth/userinfo.profile";
    var state = GetRandomString();

    SetCookies(Response, "google_state", state);

    var queryParams = new Dictionary<string, string>
    {
        {"client_id", _clientId},
        {"redirect_uri", redirectUri},
        {"scope", scope},
        {"state", state},
        {"include_granted_scopes", "true"},
        {"response_type", "code"}
    };

    var queryString = string.Join("&", queryParams.Select(kvp => $"{kvp.Key}={kvp.Value}"));
    return $"{oauth2Endpoint}?{queryString}";
}
private 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;
}
// inject IHostEnvironment in constructor
private readonly IHostEnvironment _env;

private 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
        });
    }
}

三、Google登入回應處理

  1. redirect_uri Google登入回應端點,接收參數:
    • 使用者同意授權並登入成功,則取得 codestate 參數。
    • 使用者拒絕授權或登入失敗,則取得 error 參數。
  2. 驗證回傳的 state 是否與發送請求時一致,以防CSRF攻擊
  3. Google登入成功後,以 code 取得 access_token,以 access_token 取得使用者資訊;再登入或註冊使用者。
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(this._env.APIEndpoint.OAuthGoogle, data).subscribe(res => {
          localStorage.setItem('user', JSON.stringify(res));
          this._router.navigateByUrl('/');
        });
      } else console.error("Google登入失敗。");
    }
}
[HttpPost]
public async Task<IActionResult> GetAuthenticateUser([FromForm] string code, [FromForm] string state)
{
    try
    {
        // 驗證state是否一致
        var storageState = Request.Cookies["state"];
        if (code.IsNullOrEmpty() || state.IsNullOrEmpty() || state != storageState)
            throw new Exception();

        Response.Cookies.Delete("state");

        // 取得Token
        var res = await ExchangeAuthorizationCodeAsync(code!);
        var accessToken = res?.AccessToken ?? throw new Exception();

        // 取得使用者資訊
        var profile = await GetUserProfile(accessToken);
        var email = profile?.Email;
        if (profile!= null && email != null)
        {
            // 以信箱從資料庫取得使用者
            var user = await _userService.GetByEmailAsync(email);
            if (user == null)
            {
                // 若使用者不存在則建立新使用者(註冊)
                AppUser newUser = new();
                await _userService.InsetAsync(newUser);
                return Ok(newUser);
            }
            // 使用者已存在(登入)
            return Ok(user);
        }
        throw new Exception("無法取得使用者資訊。");
    }
    catch (Exception ex)
    {
        Log.Error(ex.Message);
        return StatusCode(StatusCodes.Status500InternalServerError);
    }
}

取得Token

  1. code 請求取得 Token。
  2. 取得 Token 的 redirect_uri 需與導向至Google登入頁面redirect_uri 一致,否則會出現錯誤 redirect_uri_mismatch
public async Task<TokenResponse?> ExchangeAuthorizationCodeAsync(string code)
{
    var tokenEndpoint = "https://oauth2.googleapis.com/token";
    var redirectUri = "http://localhost:7300/api/OAuth/Callback";

    var requestBody = new Dictionary<string, string>
    {
        { "client_id", _clientId },
        { "client_secret", _clientSecret },
        { "code", code },
        { "redirect_uri",  redirectUri },
        { "grant_type", "authorization_code" }
    };
    var requestContent = new FormUrlEncodedContent(requestBody);

    using HttpClient client = _httpClient.CreateClient();
    var response = await client.PostAsync(tokenEndpoint, requestContent);

    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);
}
  1. Token
public class TokenResponse
{
    [JsonPropertyName("access_token")]
    public string? AccessToken { get; set; }
    [JsonPropertyName("token_type")]
    public string? TokenType { get; set; }
    [JsonPropertyName("expires_in")]
    public int ExpiresIn { get; set; }
    [JsonPropertyName("refresh_token")]
    public string? RefreshToken { get; set; }
    [JsonPropertyName("scope")]
    public string? Scope { get; set; }
}

取得使用者資訊

  1. access_token 請求取得使用者資訊。
public async Task<UserProfile?> GetUserProfile(string accessToken)
{
    var userInfoEndpoint = "https://www.googleapis.com/oauth2/v3/userinfo";

    using HttpClient client = _httpClient.CreateClient();
    client.DefaultRequestHeaders.Authorization = 
        new AuthenticationHeaderValue("Bearer", accessToken);

    var response = await client.GetAsync(userInfoEndpoint);
    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<UserProfile>(resContent);
}
  1. 使用者資訊
public class UserProfile
{
    [JsonPropertyName("sub")]
    public string? Sub { get; set; }
    [JsonPropertyName("name")]
    public string Name { get; set; } = string.Empty;
    [JsonPropertyName("given_name")]
    public string? GivenName { get; set; }
    [JsonPropertyName("picture")]
    public string? Picture { get; set; }
    [JsonPropertyName("email")]
    public string? Email { get; set; }
    [JsonPropertyName("email_verified")]
    public bool EmailVerified { get; set; } = false;
    [JsonPropertyName("locale")]
    public string? Locale { get; set; }
}

參考資料