ABOUT ME

-

Total
-
  • Rust: OAuth2 구글, Github, 카카오, 네이버 로그인
    컴퓨터/Rust 2023. 8. 6. 12:58
    728x90
    반응형

    만든 것

    로그인

    만든 것: 사이트 내 회원가입 (편의상 내부 Vector), 로그인 (내부 db, 구글, Github, 카카오, 네이버), 프로필

     

    spring boot으로 처음 로그인을 만들어보다가 스프링 설정이 너무 어색해서

    자주 사용하던 Rust로 간단히 만들어 보기로 했다. (Rust yew 프론트엔드 + actix-rs 백엔드)

     

    OAuth2

    간단히 각 플랫폼마다 로그인하고 유저 정보를 조회하는 방법이다.

     

    Google

    OAuth2 클라이언트 아이디와 리다이렉트 경로가 필요하다.

    https://accounts.google.com/o/oauth2/v2/auth 에 forward, 코드 요청하고 (params: redirect_uri, client_id, access_type, response_type, prompt, scope, state (주로 리다이렉트 경로 + csrf) )

    https://oauth2.googleapis.com/token 에 POST, 토큰 얻고 (params: grant_type=authorization_code, redirect_uri, client_id, code, client_secret)

    https://www.googleapis.com/oauth2/v1/userinfo 에 GET, 유저 정보를 얻는다. (token에서 받은 ID Token으로 Bearer Auth + Access Token)

    frontend

    pub fn get_google_url(from: Option<&str>) -> String {
        let client_id = std::env!("GOOGLE_OAUTH_CLIENT_ID");
        let redirect_uri = std::env!("GOOGLE_OAUTH_REDIRECT_URL");
    
        let root_url = "https://accounts.google.com/o/oauth2/v2/auth";
        let mut options = HashMap::new();
        options.insert("redirect_uri", redirect_uri);
        options.insert("client_id", client_id);
        options.insert("access_type", "offline");
        options.insert("response_type", "code");
        options.insert("prompt", "consent");
        options.insert(
            "scope",
            "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
        );
        options.insert("state", from.unwrap_or_default());
    
        let url = Url::parse_with_params(root_url, &options).unwrap();
        let qs = url.query().unwrap();
    
        format!("{}?{}", root_url, qs)
    }

    backend

    #[derive(Deserialize)]
    pub struct GoogleOAuthToken {
        pub access_token: String,
        pub id_token: String,
    }
    
    
    #[derive(Deserialize)]
    pub struct GoogleUserResult {
        pub id: String,
        pub email: String,
        pub verified_email: bool,
        pub name: String,
        pub given_name: String,
        pub family_name: Option<String>,
        pub picture: String,
        pub locale: String,
    }
    
    /// 구글 토큰 얻기
    pub async fn get_google_oauth_token(
        authorization_code: &str,
        data: &web::Data<AppState>,
    ) -> Result<GoogleOAuthToken, Box<dyn Error>> {
        let redirect_url = data.env.google_oauth_redirect_url.to_owned();
        let client_secret = data.env.google_oauth_client_secret.to_owned();
        let client_id = data.env.google_oauth_client_id.to_owned();
    
        let root_url = "https://oauth2.googleapis.com/token";
        let client = Client::new();
    
        let params = [
            ("grant_type", "authorization_code"),
            ("redirect_uri", redirect_url.as_str()),
            ("client_id", client_id.as_str()),
            ("code", authorization_code),
            ("client_secret", client_secret.as_str()),
        ];
        let response = client.post(root_url).form(&params).send().await?;
    
        if response.status().is_success() {
            let oauth_response = response.json::<GoogleOAuthToken>().await?;
            Ok(oauth_response)
        } else {
            let message = "An error occurred while trying to retrieve access token.";
            Err(From::from(message))
        }
    }
    
    /// 구글 유저 정보 조회
    pub async fn get_google_user(
        access_token: &str,
        id_token: &str,
    ) -> Result<GoogleUserResult, Box<dyn Error>> {
        let client = Client::new();
        let mut url = Url::parse("https://www.googleapis.com/oauth2/v1/userinfo").unwrap();
        url.query_pairs_mut().append_pair("alt", "json");
        url.query_pairs_mut()
            .append_pair("access_token", access_token);
    
        let response = client.get(url).bearer_auth(id_token).send().await?;
    
        if response.status().is_success() {
            let user_info = response.json::<GoogleUserResult>().await?;
            Ok(user_info)
        } else {
            let message = "An error occurred while trying to retrieve user information.";
            Err(From::from(message))
        }
    }

     

    Github

    OAuth2 클라이언트 아이디와 리다이렉트 경로가 필요하다.

    https://github.com/login/oauth/authorize 에 forward, 코드 요청하고 (params: redirect_uri, client_id, access_type, scope, state (주로 리다이렉트 경로 + csrf) )

    https://github.com/login/oauth/access_token 에 POST, 토큰 얻고 (params: client_id, code, client_secret, 이때 Accept: application/json 하면 json으로 쉽게 받을 수 있음)

    https://api.github.com/user 에 GET, 유저 정보를 얻는다. (Bearer Auth (access token), User-Agent를 아무거나로 설정 안 하면 404 뜨는 것 같다)

     

    frontend

    pub fn get_github_url(from: Option<&str>) -> String {
        let client_id = std::env!("GITHUB_OAUTH_CLIENT_ID");
        let redirect_uri = std::env!("GITHUB_OAUTH_REDIRECT_URL");
    
        let root_url = "https://github.com/login/oauth/authorize";
        let mut options = HashMap::new();
        options.insert("redirect_uri", redirect_uri);
        options.insert("client_id", client_id);
        options.insert("scope", "read:user user:email");
        options.insert("state", from.unwrap_or_default());
    
        let url = Url::parse_with_params(root_url, &options).unwrap();
        let qs = url.query().unwrap();
    
        format!("{}?{}", root_url, qs)
    }

    backend

    #[derive(Deserialize)]
    pub struct BasicOauthToken {
        pub access_token: String,
    }
    
    #[derive(Deserialize)]
    pub struct GitHubUserResult {
        pub login: String,
        pub avatar_url: String,
        pub email: String,
    }
    
    pub async fn get_github_oauth_token(
        authorization_code: &str,
        data: &web::Data<AppState>,
    ) -> Result<BasicOauthToken, Box<dyn Error>> {
        let client_id = data.env.github_oauth_client_id.to_owned();
        let client_secret = data.env.github_oauth_client_secret.to_owned();
    
        let root_url = "https://github.com/login/oauth/access_token";
    
        let client = Client::new();
    
        let params = [
            ("client_id", client_id.as_str()),
            ("code", authorization_code),
            ("client_secret", client_secret.as_str()),
        ];
    
        let response = client
            .post(root_url)
            .header("Accept", "application/json")
            .form(&params)
            .send()
            .await?;
    
        if response.status().is_success() {
            let oauth_response = response.json::<BasicOauthToken>().await?;
            Ok(oauth_response)
        } else {
            let message = "An error occurred while trying to retrieve the access token.";
            Err(From::from(message))
        }
    }
    
    pub async fn get_github_user(access_token: &str) -> Result<GitHubUserResult, Box<dyn Error>> {
        let root_url = "https://api.github.com/user";
    
        let client = Client::new();
    
        let response = client
            .get(root_url)
            .header(reqwest::header::USER_AGENT, "blog-rs")
            .bearer_auth(access_token)
            .send()
            .await?;
    
        if response.status().is_success() {
            let user_info = response.json::<GitHubUserResult>().await?;
            Ok(user_info)
        } else {
            // Read the response text to get the error message
            let error_text = response.text().await?;
            println!("Error: {}", error_text);
            let message = "An error occurred while trying to retrieve user information.";
            Err(From::from(message))
        }
    }

     

    네이버

    OAuth2 클라이언트 아이디와 리다이렉트 경로가 필요하다.

    https://nid.naver.com/oauth2.0/authorize 에 forward, 코드 요청하고 (params: redirect_uri, client_id, state (주로 리다이렉트 경로 + csrf), response_type(code)  )

    https://nid.naver.com/oauth2.0/token 에 POST, 토큰 얻고 (params: grant_type=authorization_code, redirect_uri, client_id, code, client_secret)

    https://openapi.naver.com/v1/nid/me 에 GET, 유저 정보를 얻는다. (Access Token으로 Bearer Auth)

     

    frontend

    pub fn get_naver_url(from: Option<&str>) -> String {
        let client_id = std::env!("NAVER_OAUTH_CLIENT_ID");
        let redirect_uri = std::env!("NAVER_OAUTH_REDIRECT_URL");
    
        let root_url = "https://nid.naver.com/oauth2.0/authorize";
    
        let mut options = HashMap::new();
        options.insert("redirect_uri", redirect_uri);
        options.insert("client_id", client_id);
        options.insert("state", from.unwrap_or_default());
        options.insert("response_type", "code");
    
        let url = Url::parse_with_params(root_url, &options).unwrap();
        let qs = url.query().unwrap();
    
        format!("{}?{}", root_url, qs)
    }

    backend

    #[derive(Deserialize)]
    pub struct BasicOauthToken {
        pub access_token: String,
    }
    
    #[derive(Deserialize)]
    pub struct NaverUserResult {
        pub resultcode: String,
        pub message: String,
        pub response: NaverUserResponse,
    }
    
    #[derive(Deserialize)]
    pub struct NaverUserResponse {
        pub id: String,
        pub nickname: String,
        pub email: String,
        pub profile_image: String,
        pub name: Option<String>,
        pub gender: Option<String>,
        pub age: Option<String>,
        pub birthday: Option<String>,
        pub birthyear: Option<String>,
        pub mobile: Option<String>,
    }
    
    pub async fn get_naver_oauth_token(
        authorization_code: &str,
        data: &web::Data<AppState>,
    ) -> Result<BasicOauthToken, Box<dyn Error>> {
        let redirect_url = data.env.naver_oauth_redirect_url.to_owned();
        let client_id = data.env.naver_oauth_client_id.to_owned();
        let client_secret = data.env.naver_oauth_client_secret.to_owned();
    
        let root_url = "https://nid.naver.com/oauth2.0/token";
    
        let client = Client::new();
    
        let params = [
            ("grant_type", "authorization_code"),
            ("client_id", client_id.as_str()),
            ("client_secret", client_secret.as_str()),
            ("redirect_uri", redirect_url.as_str()),
            ("code", authorization_code),
        ];
    
        println!("params: {:#?}", params);
    
        let response = client.post(root_url).form(&params).send().await?;
    
        if response.status().is_success() {
            // let response_value: serde_json::Value = response.json().await?;
            // println!("Response: {:#?}", response_value);
            // let oauth_response = serde_json::from_value::<NaverOauthToken>(response_value)?;
            let oauth_response = response.json::<BasicOauthToken>().await?;
            Ok(oauth_response)
        } else {
            let message = "An error occurred while trying to retrieve the access token.";
            Err(From::from(message))
        }
    }
    
    pub async fn get_naver_user(access_token: &str) -> Result<NaverUserResult, Box<dyn Error>> {
        let root_url = "https://openapi.naver.com/v1/nid/me";
    
        let client = Client::new();
    
        let response = client
            .get(root_url)
            .header(reqwest::header::USER_AGENT, "blog-rs")
            .bearer_auth(access_token)
            .send()
            .await?;
    
        if response.status().is_success() {
            // let response_value: serde_json::Value = response.json().await?;
            // println!("Response: {:#?}", response_value);
            // let user_info = serde_json::from_value::<NaverUserResult>(response_value)?;
    
            let user_info = response.json::<NaverUserResult>().await?;
            Ok(user_info)
        } else {
            // Read the response text to get the error message
            let error_text = response.text().await?;
            println!("Error: {}", error_text);
            let message = "An error occurred while trying to retrieve user information.";
            Err(From::from(message))
        }
    }

     

    카카오

    OAuth2 클라이언트 아이디와 리다이렉트 경로가 필요하다. 응답 JSON이 괴랄하다

    https://kauth.kakao.com/oauth/authorize 에 forward, 코드 요청하고 (params: redirect_uri, client_id (REST API 키), response_type, state (주로 리다이렉트 경로 + csrf) )

    https://kauth.kakao.com/oauth/token 에 POST, 토큰 얻고 (params: grant_type=authorization_code, redirect_uri, client_id (REST API 키), code)

    https://kapi.kakao.com/v2/user/me 에 GET, 유저 정보를 얻는다. (Access Token으로 Bearer Auth)

    frontend

    pub fn get_kakao_url(from: Option<&str>) -> String {
        let client_id = std::env!("KAKAO_OAUTH_CLIENT_ID");
        let redirect_uri = std::env!("KAKAO_OAUTH_REDIRECT_URL");
    
        let root_url = "https://kauth.kakao.com/oauth/authorize";
    
        let mut options = HashMap::new();
        options.insert("redirect_uri", redirect_uri);
        options.insert("client_id", client_id);
        options.insert("state", from.unwrap_or_default()); 
        options.insert("response_type", "code");
    
        let url = Url::parse_with_params(root_url, &options).unwrap();
        let qs = url.query().unwrap();
    
        format!("{}?{}", root_url, qs)
    }

    backend

    #[derive(Debug, Deserialize)]
    pub struct KakaoUserResult {
        pub id: u64,
        pub has_signed_up: Option<bool>,
        pub connected_at: Option<String>,
        pub synched_at: Option<String>,
        pub properties: Option<HashMap<String, String>>,
        pub kakao_account: Option<KakaoAccount>,
        pub for_partner: Option<Partner>,
    }
    
    #[derive(Debug, Deserialize)]
    pub struct KakaoAccount {
        pub profile_needs_agreement: Option<bool>,
        pub profile_nickname_needs_agreement: Option<bool>,
        pub profile_image_needs_agreement: Option<bool>,
        pub profile: Option<Profile>,
        pub name_needs_agreement: Option<bool>,
        pub name: Option<String>,
        pub email_needs_agreement: Option<bool>,
        pub is_email_valid: Option<bool>,
        pub is_email_verified: Option<bool>,
        pub email: Option<String>,
        pub age_range_needs_agreement: Option<bool>,
        pub age_range: Option<String>,
        pub birthyear_needs_agreement: Option<bool>,
        pub birthyear: Option<String>,
        pub birthday_needs_agreement: Option<bool>,
        pub birthday: Option<String>,
        pub birthday_type: Option<String>,
        pub gender_needs_agreement: Option<bool>,
        pub gender: Option<String>,
        pub phone_number_needs_agreement: Option<bool>,
        pub phone_number: Option<String>,
        pub ci_needs_agreement: Option<bool>,
        pub ci: Option<String>,
        pub ci_authenticated_at: Option<String>,
    }
    
    #[derive(Debug, Deserialize)]
    pub struct Profile {
        pub nickname: Option<String>,
        pub thumbnail_image_url: Option<String>,
        pub profile_image_url: Option<String>,
        pub is_default_image: Option<bool>,
    }
    
    #[derive(Debug, Deserialize)]
    pub struct Partner {
        pub uuid: Option<String>,
    }
    
    pub async fn get_kakao_oauth_token(
        authorization_code: &str,
        data: &web::Data<AppState>,
    ) -> Result<BasicOauthToken, Box<dyn Error>> {
        let redirect_url = data.env.kakao_oauth_redirect_url.to_owned();
        let client_id: String = data.env.kakao_oauth_client_id.to_owned();
    
        let root_url = "https://kauth.kakao.com/oauth/token";
    
        let client = Client::new();
    
        let params = [
            ("grant_type", "authorization_code"),
            ("client_id", client_id.as_str()),
            ("redirect_uri", redirect_url.as_str()),
            ("code", authorization_code),
        ];
    
        let response = client
            .post(root_url)
            .header(
                "Content-Type",
                "application/x-www-form-urlencoded; charset=UTF-8",
            )
            .form(&params)
            .send()
            .await?;
    
        if response.status().is_success() {
            // let response_value: serde_json::Value = response.json().await?;
            // println!("Response: {:#?}", response_value);
            // let oauth_response = serde_json::from_value::<KakaoOauthToken>(response_value)?;
            let oauth_response = response.json::<BasicOauthToken>().await?;
            Ok(oauth_response)
        } else {
            let message = "An error occurred while trying to retrieve the access token.";
            Err(From::from(message))
        }
    }
    
    pub async fn get_kakao_user(access_token: &str) -> Result<KakaoUserResult, Box<dyn Error>> {
        let root_url = "https://kapi.kakao.com/v2/user/me";
    
        let client = Client::new();
    
        let response = client
            .get(root_url)
            .header(reqwest::header::USER_AGENT, "blog-rs")
            .bearer_auth(access_token)
            .send()
            .await?;
    
        if response.status().is_success() {
            // let response_value: serde_json::Value = response.json().await?;
            // println!("Response: {:#?}", response_value);
            // let user_info = serde_json::from_value::<KakaoUserResult>(response_value)?;
    
            let user_info = response.json::<KakaoUserResult>().await?;
            Ok(user_info)
        } else {
            // Read the response text to get the error message
            let error_text = response.text().await?;
            println!("Error: {}", error_text);
            let message = "An error occurred while trying to retrieve user information.";
            Err(From::from(message))
        }
    }

     

    REDIRECT HANDLER

    4가지 provider 한번에 처리하기

    #[get("/sessions/oauth/{provider}")]
    async fn oauth_handler(
        path: web::Path<(String,)>,
        query: web::Query<QueryCode>,
        data: web::Data<AppState>,
    ) -> ActixResult<impl Responder> {
        let provider = match path.into_inner().0.as_str() {
            "google" => OAuthProvider::Google,
            "github" => OAuthProvider::GitHub,
            "kakao" => OAuthProvider::Kakao,
            "naver" => OAuthProvider::Naver,
            _ => return Ok(HttpResponse::BadRequest().finish()),
        };
    
        let code = &query.code;
        let state = &query.state;
    
        if code.is_empty() {
            return Err(ErrorBadRequest("Authorization code not provided!"));
        }
    
        let token_response = match provider {
            OAuthProvider::Google => get_google_oauth_token(code.as_str(), &data)
                .await
                .map(OAuthTokenResponse::Google),
            OAuthProvider::GitHub => get_github_oauth_token(code.as_str(), &data)
                .await
                .map(OAuthTokenResponse::GitHub),
            OAuthProvider::Kakao => get_kakao_oauth_token(code.as_str(), &data)
                .await
                .map(OAuthTokenResponse::Kakao),
            OAuthProvider::Naver => get_naver_oauth_token(code.as_str(), &data)
                .await
                .map(OAuthTokenResponse::Naver),
        }
        .map_err(to_bad_gateway)
        .unwrap();
    
        // Deal OAuth2 providers
        let user_info = match token_response {
            OAuthTokenResponse::Google(token_response) => {
                fetch_user_info(
                    &provider,
                    &token_response.access_token,
                    Some(&token_response.id_token),
                )
                .await?
            }
            OAuthTokenResponse::GitHub(token_response) => {
                fetch_user_info(&provider, &token_response.access_token, None).await?
            }
            OAuthTokenResponse::Naver(token_response) => {
                fetch_user_info(&provider, &token_response.access_token, None).await?
            }
            OAuthTokenResponse::Kakao(token_response) => {
                fetch_user_info(&provider, &token_response.access_token, None).await?
            }
        };
    
        let mut vec = match data.db.lock() {
            Ok(v) => v,
            Err(_) => {
                return Ok(HttpResponse::InternalServerError()
                    .json(serde_json::json!({"status": "error", "message": "Database error"})))
            }
        };
    
        let user_id = find_or_create_user(user_info, &mut vec).await;
    
        let now = Utc::now();
        let token = match encode(
            &Header::default(),
            &TokenClaims {
                sub: user_id,
                exp: (now + Duration::minutes(data.env.jwt_max_age)).timestamp() as usize,
                iat: now.timestamp() as usize,
            },
            &EncodingKey::from_secret(data.env.jwt_secret.as_ref()),
        ) {
            Ok(t) => t,
            Err(_) => return Ok(HttpResponse::InternalServerError().finish()),
        };
    
        let cookie = Cookie::build("token", token)
            .path("/")
            .max_age(ActixWebDuration::new(60 * data.env.jwt_max_age, 0))
            .http_only(true)
            .finish();
    
        Ok(HttpResponse::Found()
            .append_header((LOCATION, format!("{}{}", data.env.client_origin, state)))
            .cookie(cookie)
            .finish())
    }

    참고

    @Google OAuth2 문서

     

    OAuth 2.0을 사용하여 Google API에 액세스하기  |  Authorization  |  Google for Developers

    이 페이지는 Cloud Translation API를 통해 번역되었습니다.

    developers.google.com

    @Github OAuth2 문서

     

    OAuth 앱 권한 부여 - GitHub Docs

    다른 사용자가 OAuth app에 권한을 부여하도록 설정할 수 있습니다.

    docs.github.com

     

    @Kakao OAuth2 문서

     

    Kakao Developers

    카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

    developers.kakao.com

     

    @NAVER OAuth2 문서

     

    네이버 로그인 API 명세 - LOGIN

    네이버 로그인 API 명세 네이버 로그인 API는 네이버 로그인 인증 요청 API, 접근 토큰 발급/갱신/삭제 요청API로 구성되어 있습니다.

    developers.naver.com

     

    728x90

    댓글