C#/ASP.NET Core

[Asp.net core] Firebase 인증 구현기

말하는 닭 2023. 5. 5. 21:21

이 글을 시작으로 얼마나 작성해볼지는 모르겠지만, asp.net core mvc로 웹페이지 개발 중 겪었던 애로사항과 해결과정을 작성해볼 것이다. 참고로 이 글을 작성하고 있는 와중에도 개발중이라.. 자주 쓸 수 있으면 좋겠다.

 

환경

.Net Core 6.0

 

우선 인증은, 인터넷을 뒤지며 찾아보면 방법이 많긴 한데 따라하려면 안 되는 경우였다.

FirebaseAuthProvider였나? 이 클래스가 없다고 자꾸 에러를 내뱉는데 유튜브에서는 잘만 사용하더라. (사실 이때 화가 많이 남. 난 왜 안대ㅐㅐㅐㅐ) Nuget도 똑같은 걸 받았는데 안돼서 Firebase 공식 문서에 있는 FirebaseAdmin 패키지를 사용하기로 결정했다.

하지만 이 녀석도 사용이 험난했다. 하...

일단은 내가 asp.net core로 개발하려는게 잘못이 아닐까 생각이 들 정도로 시간이 오래걸리고 화도 많이 나고... 오로지 공식문서에 의존(Chat GPT한테 FirebaseAdmin으로 인증 어떻게 하냐고 물어보긴 했는데 잘 대답을 못했다)하며 하나하나 시행착오를 겪으면서 완성을 하긴 했다.

 

위와 같이 화면을 구성해줬다. 로그인 화면에 있는 비밀번호 모를 때, 로그인 상태 유지는 아직 구현을 못했다. 

 

우선 Authentication탭에서 이메일/비밀번호를 사용하기로 설정해준다.(구글은 설정만 해놓음. 언젠간 사용할 수 있겠지)

//Program.cs

var firebaseApp = FirebaseApp.Create(new AppOptions() {
	Credential = GoogleCredential.FromFile("firebase-adminsdk.json"),
	ProjectId = builder.Configuration["Project_Id"],
	ServiceAccountId = builder.Configuration["Service_Account_Id"]
});

builder.Services.AddSingleton(firebaseApp);
// Program.cs
// 아래 두 줄은 app.UseRouting()보다 나중에 작성되어야 한다.
app.UseAuthorization();
app.UseAuthentication();

Program.cs파일에 위와 같이 설정해준다.

GoogleCredential은 파이어베이스 콘솔 창 > 프로젝트 설정 > 서비스 계정 > 새 비공개 키 생성으로 가면 json파일을 하나 받을 수 있다. 이를 프로젝트 파일 안에 넣어준 것이다. 

ProjectId는 프로젝트 설정에서, ServiceAccountId는 서비스 계정에 가면 볼 수 있다. 둘은 appsetting.json에 담아놓고 불러온 것이다. 

 

그 다음 AccountController를 만들어줬다. 

// 얘는 생성자
public AccountController() {
    IConfiguration config = new ConfigurationBuilder()
        .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
        .Build();

    API_KEY = config["API_KEY"];
    Project_Id = config["Project_Id"];
    SALT = config["SALT"];

    db = FirestoreDb.Create(Project_Id);			
}

생성자에는 위와 같이 만들어줬다. API_KEY는 역시 콘솔 창 > 프로젝트 설정에 들어가면 Web Api Key라고 하는 데에서 볼 수 있다. IConfiguration은 아까 위에서 하듯이 builder로 바로 appsetiing.json의 값을 불러올 수 있으면 좋겠지만 안 되어서 이렇게 가져와야 한다. 

SALT와 db는 후술한다.

 

회원 등록은 간단한 편이었다.

[HttpPost]
public async Task<IActionResult> Register(UserRegister user) {
    if (user.Password != user.PasswordCheck) {
        ModelState.AddModelError("Error", "비밀번호를 다시 확인해주세요");
        return View();
    }

    string hashPassword = BCrypt.Net.BCrypt.HashPassword(user.Password, SALT);

    var userRecordArgs = new UserRecordArgs {
        Email = user.Email,
        Password = hashPassword,
        DisplayName = user.DisplayName,
        Disabled = false
    };

    try {
        UserRecord userRecord = await FirebaseAuth.DefaultInstance.CreateUserAsync(userRecordArgs);

        DocumentReference docRef = db.Collection("users").Document(userRecord.Uid);

        Dictionary<string, object> data = new Dictionary<string, object>() {
            { "Uid", userRecord.Uid },
            { "Email", user.Email },
            { "Password", hashPassword },  // <- 얘는 나중에 개발하면서 지워버린다...
            { "DisplayName", user.DisplayName },
            { "isAdmin", false }
        };
        await docRef.SetAsync(data);

        return RedirectToAction("Login", "Account");
    }
    catch(FirebaseAuthException e) {
        ModelState.AddModelError("Error", "회원등록에 실패하였습니다");
        ModelState.AddModelError("Error", e.Message);

        return View();
    }
}

위의 코드는 내가 작성한 회원등록 코드다. CreateUser로 사용자를 생성하고 로그인 시 이메일과 비밀번호를 대조할 수 있도록 Firestore에 저장하는 기능이다. 상단부에서 Bcrypt를 사용했는데, 그냥 저장하면 사용자의 비밀번호가 그대로 저장, 즉 내가 비밀번호를 볼 수 있다. 이러면 안 되기에 Bcrypt를 사용해서 해시시켜 저장한 것이다. 

 

여기서 문제가 또 발생한다. 해시 과정에서 seed값인 salt가 존재하는데, salt가 해시함수를 불러올 때마다 바뀐다는 것이다. 똑같은 비밀번호를 입력해도 seed값이 달라 다른 해시값이 나와서 로그인이 불가능한 사태가 발생했다. 그래서 salt값을 지정해서 같은 비밀번호에 대해 같은 해시값을 내뱉을 수 있도록 한 것이다. 나중에야 한 사실이지만, FirebaseAdmin 안에 해시시켜주는 기능이 있는 것 같다. 난 그걸 몰라서 다른 Bcrypt 패키지를 설치했고...

 

유저를 생성하려면 

UserRecord userRecord = await FirebaseAuth.DefaultInstance.CreateUserAsync(userRecordArgs);

로 생성하면 된다. 생성 시 매개변수로는 UserRecordArgs를 넘겨주면 되는데, 아래의 페이지에서 더 자세히 볼 수 있다.

https://firebase.google.com/docs/reference/admin/dotnet/class/firebase-admin/auth/user-record

 

FirebaseAdmin.Auth.UserRecord Class Reference

Type Definitions

firebase.google.com

 

회원 가입은 이렇게 되었는데...

공식문서에 있는대로 FirebaseAuth를 써서 로그인하려고 하면, 다른 인터넷 글에서는, 챗 GPT한테 물어봐도 SignInEmailAndPassword 메서드를 쓰라고 하는데, 나는 그런거 없었다. SignIn과 관련한 기능으로는 FirebaseAuth.DefaultInstance.GenerateSignInWithEmailLinkAsync밖에 없었다.(얘는 이메일 보내서 메일 안의 링크 누르면 로그인 되는 거) 있는게 뭘까.

그래서 또 다른 방법을 모색하니, REST API를 사용해서 인증을 할 수 있다고 한다. 그래서 얘를 써야지 뭐 별 수 있나. 

Rest API로 로그인을 시도하려면 파이어베이스에서 자동으로 로그인해주는게 없는 것 같다. 그래서 로그인 시의 입력값이랑 Firestore에 저장된 유저 데이터와 일치할 때 토큰을 발급받는 식이다.

 

그러려면 일단 데이터를 먼저 저장시키자. Firestore을 사용하려면 새로운 패키지를 다운받아야 한다.

요놈을 다운받아주자.

 

Firestore에는 Dictionary<string, object>형태로 저장을 시켜야하나보다. (이부분은 나도 잘 모른다) 

그래서 data 변수를 만들어서 저장시킬거다. 

Firestore를 사용하기 위해서는 

string path = AppDomain.CurrentDomain.BaseDirectory + @"firebase-adminsdk.json";
Environment.SetEnvironmentVariable("GOOGLE_APPLICATION_CREDENTIALS", path);

Program.cs에 위와 같이 환경변수를 추가시켜줘야 사용할 수 있다. 안하면 credential이 어쩌네 하며 에러낸다. 파일 이름 앞의 @는 '문자 그대로'의 역할을 한다는데, 잘은 모르겠다.

 

Firestore은 사용하기

FirebaseDb 생성 > DocumentReference 생성 > Set이나 Get으로 데이터를 가져오거나 저장시킬 수 있음

 

데이터를 저장시키는데 성공한다면 Login 페이지로 넘어가게 만들었다.

 

[HttpPost]
public async Task<IActionResult> Login(UserLogin user) {

    string hashPassword = BCrypt.Net.BCrypt.HashPassword(user.Password, SALT);

    var json = JsonConvert.SerializeObject(new {
        email = user.Email,
        password = hashPassword,
        returnSecureToken = true
    });

    try {
        using(HttpClient httpClient = new HttpClient()) {

            var content = new StringContent(json, Encoding.UTF8, "application/json");
            var response = await httpClient.PostAsync("https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=" + API_KEY, content);

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

            if(response.IsSuccessStatusCode) {
                var firebaseToken = JsonConvert.DeserializeObject<FirebaseResponse>(responseContent);

                DocumentReference docRef = db.Collection("users").Document(firebaseToken!.LocalId);
                DocumentSnapshot snapshot = await docRef.GetSnapshotAsync();

                Dictionary<string, object> userData = snapshot.ToDictionary();

                return RedirectToAction("Index", "Home");
            }
            else {
                ModelState.AddModelError("Error", "아이디 혹은 비밀번호가 일치하지 않습니다");
                return View();
            }
        }
    }
    catch {
        ModelState.AddModelError("Error", "에러가 발생하였습니다. 다시 시도해주십시오");
        return View();
    }
}

위의 코드는 로그인을 하는 코드다.

https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=[API_KEY]

의 주소로 content(이메일과 패스워드 포함시킨)를 보내면 일치 시 토큰을 반환한다. 토큰에는 Firestore에 저장할 때 쓴 Uid가 반환된다.이 Uid를 이용해 snapshot(데이터 객체?)을 받아올 수 있다. snapshot은 ToDictionary 메서드를 통해 딕셔너리 형태로 변환시켜 쓸 수 있다. 그러면 회원가입 시 저장한 데이터들을 사용할 수 있는 것이다.

 

코드를 보다보면 FirebaseResponse 클래스가 보이는데, 역직렬화하려는데 없길래 아래와 같이 그냥 클래스를 만들었다.

public class FirebaseResponse {
    public string Kind { get; set; }
    public string LocalId { get; set; }
    public string Email { get; set; }
    public string DisplayName { get; set; }
    public string IdToken { get; set; }
    public bool Registered { get; set; }
    public string RefreshToken { get; set; }
    public int ExpiresIn { get; set; }
}

 

이걸 구현하는데 며칠을 썼다면 믿으시겠습니까? 자료를 못찾는건지..  아님 웹페이지를 만들어본 게 이게 처음이라 그런건가...

 

 

아, 그리고 혹시 이 글을 보는 여러분, 더 나은 방법이 있다면 댓글에 남겨주십시오.