iOS Supabase 카카오 애플 로그인

임클·2025년 6월 5일
0

Swift

목록 보기
38/38

소셜플랫폼 로그인 / 로그아웃

소셜 플랫폼 설정

  1. Supabase에서 프로젝트 생성
  2. Authentication → Sign In / Providers
  3. Auth Providers에서 원하는 소셜 플랫폼 활성화 (필자는 카카오,애플 선택)
  1. 카카오
  • REST API Key는 카카오 디벨로퍼에서 복,붙하기
  • Client Secret Code
여기서 코드 생성 후 복,붙하기
  • Supabase에서 Callback URL은 복사해서 여기에 저장하기

  • 필수 권한으로 필자는 이메일을 추가했다. (해당 방법은 다른 블로그에 정리가 잘 나와있다.)

Client에서 로그인 로그아웃 처리 (카카오, 애플 동일)

import Supabase

enum SocialType: String {
    case kakao = "kakao"
    case apple = "apple"
}
...

let client = SupabaseClient(supabseURL: _ , supabaseKey: _)

// 해당 코드를 로그인할 버튼에 이어주면 끝
// MARK: 로그인
func signInWithSocial(type: SocialType) async throws {
        let authSession = try await client.auth.signInWithOAuth(provider: Provider(rawValue: type.rawValue)!, redirectTo: redirectURL) { session in
        // 아래 값은 true/false로 바뀌는 동작 보고 결정
            session.prefersEphemeralWebBrowserSession = false
        }
        // 키체인으로 accessToken, refreshToken 저장
        self.saveSupabaseTokens(authSession: authSession)
        print("\(type.rawValue) 로그인")
        // 화면 이동
        self.gotoMainView()
}

// MARK: 로그아웃
func signOutSupabase() async throws {
        do {
            try await client.auth.signOut()
        } catch {
            print("Supabase signOut 실패:", error.localizedDescription)
        }
        print("로그아웃")
        // 키체인으로 저장된 토큰들 삭제
        self.deleteSupabaseTokens()
        self.gotoLoginView()
}
  1. 애플

애플 설정은 너무나도 잘 정리되어 있는 공식문서를 첨부하겠다.

https://docs-pgth9qjfy-supabase.vercel.app/docs/guides/auth/social-login/auth-apple

회원탈퇴

회원탈퇴는 카카오, 애플에서 제공하는 RestAPI로 직접 호출해야 한다.
이유는 카카오,애플의 accessToken (또는 RefreshToken이 필요하기 때문)

카카오

import Alamofire

private func unlinkKakaoAccout(_ token: String)  {

        print("읽어온 액세스 토큰 (provider : 카카오 ): \(token)")

        let url = "https://kapi.kakao.com/v1/user/unlink"
        let headers: HTTPHeaders = [
            "Authorization": "Bearer \(token)"
        ]

        Task {
            AF.request(url, method: .post, headers: headers)
                .validate()
                .responseData { response in
                    switch response.result {
                    case .success:
                        print("리프레시 토큰 리보크 성공. 상태 코드:", response.response?.statusCode ?? -1)
                        let bodyString = response.data.flatMap { String(data: $0, encoding: .utf8) } ?? "응답 바디 없음"
                        print(bodyString)
                    case .failure(let error):
                        let statusCode = response.response?.statusCode ?? -1
                        let bodyString = response.data.flatMap { String(data: $0, encoding: .utf8) } ?? "응답 바디 없음"
                        print("리프레시 토큰 리보크 실패. HTTP \(statusCode), 응답: \(bodyString), 오류: \(error.localizedDescription)")
                    }
                }
        }
    }

애플

애플은 하단에 Supabase에서 Edge Functions을 사용한 글을 읽어야 이해할 수 있다.

import Alamofire

private func revokeToken(_ token: String) {
        let url = "https://appleid.apple.com/auth/revoke"
        let clientID = "사용자의 서비스 아이디 등록(애플 개발자 홈페이지에서 만들어야함)"

        Task {
            let clientSecret: String
            do {
                clientSecret = try await requestSecret() // Edge Functions으로 가져오기
            } catch {
                print("Client Secret 획득 실패:", error.localizedDescription)
                return
            }

            //  요청 파라미터
            let params: [String: String] = [
                "client_id": clientID,
                "client_secret": clientSecret,
                "token": token,
                "token_type_hint": "access_token"
            ]

            //  Alamofire 요청
            AF.request(
                url,
                method: .post,
                parameters: params,
                encoder: URLEncodedFormParameterEncoder.default,
                headers: ["Content-Type": "application/x-www-form-urlencoded"]
            )
            .validate()
            .response { response in
                switch response.result {
                case .success:
                    print("토큰 리보크 성공. 상태 코드:", response.response?.statusCode ?? -1)
                case .failure(let error):
                    print("토큰 리보크 실패. HTTP \(response.response?.statusCode ?? -1) 오류: \(error.localizedDescription)")
                }
            }

        }
    }

통합 관리 함수

    // MARK: 회원탈퇴
    /// 1. 로그인 다시 해서 provieder.access / refresh 토큰 수령
    /// 2. 소셜 서비스에서 unlink / revoke 수행
    /// 3. 슈파베이스 row 삭제
    func deleteUserInSupabase() async throws {
        // 1) 현재 세션을 가져와서, 어떤 provider(kakao or apple)인지 판별
        let session = try await client.auth.session
        guard
            let rawProvider = session.user.appMetadata["provider"]?.rawValue,
            let socialProvider = Provider(rawValue: rawProvider)
        else {
            print("지원하지 않는 로그인 방식이거나 provider 정보가 없습니다.")
            return
        }

        // 2) provider에 따라 호출할 함수 결정
        let performUnlinkOrRevoke: (String) async throws -> Void
        switch socialProvider {
        case .kakao:
            print("unlinkKakao")
            performUnlinkOrRevoke = unlinkKakaoAccout(_: )
        case .apple:
            print("Revoke Apple")
            performUnlinkOrRevoke = revokeToken(_: )
        default:
            // 만약 다른 소셜이 추가되면 여기서 분기 처리 가능
            return
        }

        // 3) 재로그인하여 providerToken을 새로 받아오기
        //    (기존 토큰이 만료됐을 수 있기 때문)
        let authSession: Supabase.Session
        do {
            authSession = try await client.auth.signInWithOAuth(
                provider: socialProvider,
                redirectTo: redirectURL
            ) { session in
                session.prefersEphemeralWebBrowserSession = false
            }
        } catch {
            print("재로그인 실패:", error.localizedDescription)
            return
        }

        guard let providerToken = authSession.providerToken else {
            print("새로 받은 providerToken이 없습니다.")
            return
        }

        // 4) providerToken을 이용해 unlink 또는 revoke 호출
        do {
            try await performUnlinkOrRevoke(providerToken)
        } catch {
            print("Provider 연결 해제 실패:", error.localizedDescription)
            return
        }

        // 5) Supabase 측 사용자 삭제 RPC 호출 → 로그아웃
        do {
            _ = try await client
                .rpc("delete_current_user")
                .execute()
            try await signOutSupabase()
        } catch {
            print("supabase User 삭제 실패 :", error.localizedDescription)
        }
    }

Supabase에서 설정해야 하는 것들

Supabase의 auth.users, public.user 테이블의 row 삭제

Delete Current User Function
client에서 회원탈퇴한 정보를 삭제하기 위해 사용

create or replace function delete_current_user()
returns void
language plpgsql
security definer
set search_path = public, auth, pg_catalog
as $$
declare
  uid uuid := auth.uid();  -- 현재 로그인된 사용자의 ID
begin
  -- (1) public.user 테이블에서 현재 유저의 프로필(추가 정보) 삭제
  delete from public."user"
  where id = uid;

  -- (2) auth.users 테이블에서 현재 유저의 인증 정보 삭제
  delete from auth.users
  where id = uid;
end;
$$;

client에서는 다음과 같이 사용

try await client
           .rpc("delete_current_user")
           .execute()

Supabase의 auth.users와 public.user테이블 연결 및 권한 설정

User Management Starter
auth.users는 client에서 수정할 수 없기 때문에 client에서 수정할 수 있도록 public.user 테이블을 생성
이때 auth.users.uid 와 public.user.id는 외래키 관계

-- public.user에 function 추가
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.user (id, email, name)
  values (new.id, new.email, new.raw_user_meta_data ->> 'name');
  return new;
end;
$$;

-- auth.users에 trigger 추가
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

-- 1) SELECT 정책: 인증된 사용자는 자신의 행(id)이 일치할 때만 SELECT 허용
CREATE POLICY "select_self_user"
  ON public."user"
  FOR SELECT
  USING ( auth.uid() = id );

-- 2) UPDATE 정책: 인증된 사용자는 자신의 행(id)이 일치할 때만 UPDATE 허용
CREATE POLICY "update_self_user"
  ON public."user"
  FOR UPDATE
  USING ( auth.uid() = id );

애플 로그인 한정

Edge Functions으로 client_secret 키 전송

애플 로그인 정보를 삭제하기 위해선 apple의 revoke api call을 해야한다.
이때 필요한 client_secret의 값을 client에서 만들기 위해서는 보안키를 앱 번들에 저장해야하는데 보안상 위험하다. 그래서 원래는 Back-End 서버에서 처리를 해야하지만, 필자는 따로 서버가 없고, Supabase에서 해결하고자 찾은 방법중 하나이다.
Edge Functions의 Secrets에 애플 로그인을 하기 위해 만들었던 client_secret 값을 저장하고 필요할때 호출하기 위해 사용
참고 : 해당 값은 6개월마다 교체를 해야한다. 필자는 보안키를 잃어버리지 않기 위해 Supabase의 Storage에 저장했다.

// index.ts
// 1) CORS 헤더 설정
const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey",
  "Access-Control-Allow-Methods": "POST, GET, OPTIONS, PUT, DELETE"
};
// 2) 환경 변수에서 CLIENT_SECRET 값 읽어오기
//    만약 없으면 빈 문자열을 반환하게 처리
const CLIENT_SECRET = Deno.env.get("CLIENT_SECRET") ?? "";
// 3) 모든 요청을 처리하는 Deno.serve
Deno.serve(async (req)=>{
  const url = new URL(req.url);
  const pathname = url.pathname;
  // GET /generate-client-secret 요청 처리
  // 필자의 Edge Functions 함수명이 swift-api이다. 
  // /generate-client-secret은 endpoint는 api를 구분하기 쉽게 작성함(작성안해도 됨)
  if ((pathname === "/swift-api/generate-client-secret" || pathname === "/generate-client-secret") && req.method === "GET") {
    return new Response(JSON.stringify({
      client_secret: CLIENT_SECRET
    }), {
      status: 200,
      headers: {
        ...corsHeaders,
        "Content-Type": "application/json"
      }
    });
  }
  // 그 외 요청은 404 반환
  return new Response(JSON.stringify({
    error: "Not Found",
    path: pathname
  }), {
    status: 404,
    headers: {
      ...corsHeaders,
      "Content-Type": "application/json"
    }
  });
});

여기다가 Key값으로 CLIENT_SECRET로 저장했다.

client에서 호출하는 방법

 struct SecretResponse: Codable {
        let client_secret: String
}

...
private func requestSecret() async throws -> String {

// 호출할 Edge Function 이름
let functionName = "swift-api/generate-client-secret"

let response: SecretResponse = try await client.functions.invoke(
            functionName,
            options: FunctionInvokeOptions(
                method: .get,
                headers: [
                    "Authorization": "Bearer \(token)"
                    // 보안 연결에 사용되는 헤더
                    // 이 토큰은 supabase에서 OAUTH 성공할 때 제공하는 AccessToken 입력
                    // 필자는 로그인(OAUTH 인증)성공시 해당값을 키체인으로 저장함. 
                ]
            )
        )
return response.client_secret
}

부록

KeyChain 관리 코드

//
//  KeycahinHelper.swift
//  KakaoLoginTest
//
//  Created by yimkeul on 6/3/25.
//

import Foundation
import Security

enum KeychainService {
    static let supabaseAccess  = "SupabaseAccessToken"
    static let supabaseRefresh = "SupabaseRefreshToken"
}

final class KeychainHelper {
    static let shared = KeychainHelper()
    private init() {}

    /// Data를 주어진 service/account 키로 저장
    func save(_ data: Data, service: String, account: String = "current_user") {
        // 1. 동일한 service/account가 이미 있으면 삭제
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: account
        ]
        SecItemDelete(query as CFDictionary)

        // 2. 새로 저장
        let attributes: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: account,
            kSecValueData: data
        ]
        SecItemAdd(attributes as CFDictionary, nil)

        print("키체인 \(service) - \(String(describing: String(data: data, encoding: .utf8)!.prefix(10))) 저장 성공")
    }

    /// 주어진 service/account로 저장된 Data를 가져옴
    func read(service: String, account: String = "current_user") -> Data? {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: account,
            kSecReturnData: true,
            kSecMatchLimit: kSecMatchLimitOne
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess else { return nil }
        print("키체인 \(service) 불러오기")
        return result as? Data
    }

    /// 주어진 service/account에 저장된 아이템 삭제
    func delete(service: String, account: String = "current_user") {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: account
        ]
        SecItemDelete(query as CFDictionary)
        print("키체인 \(service) 삭제")
    }
}
    // MARK: Supabase Key 관리
    private func deleteSupabaseTokens() {
        KeychainHelper.shared.delete(service: "SupabaseAccessToken", account: "current_user")
        KeychainHelper.shared.delete(service: "SupabaseRefreshToken", account: "current_user")
        print("키체인에서 supabase에 연결되어있는 토큰(access,refresh) 삭제")
    }

    private func saveSupabaseTokens(authSession: Supabase.Session)  {
        let accessToken = authSession.accessToken
        let refreshToken = authSession.refreshToken

        KeychainHelper.shared.save(
            Data(accessToken.utf8),
            service: KeychainService.supabaseAccess
        )
        KeychainHelper.shared.save(
            Data(refreshToken.utf8),
            service: KeychainService.supabaseRefresh
        )
    }
profile
iOS를 공부하는 임클입니다.

0개의 댓글