카테고리 없음

Flutter + Springboot 로 Firebase Auth 제대로 쓰자

cocho/kuby 2024. 2. 13. 09:50

Flutter 에서 Firebase 의 Authentication 서비스를 이용해, Spring boot 로 구현 된 API 서버와 함께 사용하는 방법을 알아보겠습니다.

Firebase 인증으로 구현 된 앱에서 API 를 호출 할때, Bearer 토큰을 Authorization 헤더로 넘겨주어야 합니다.

요청예시

GET /me
Authorization: Bearer {firebase id token}

만약 Dio 패키지를 사용 할 경우, dio 에서 제공하는 InterceptorsWrapper 를 이용해서 구현 할 수 있습니다


class FirebaseBearerInterceptor extends InterceptorsWrapper {
  @override
  void onRequest(
      RequestOptions options, RequestInterceptorHandler handler) async {
    if (FirebaseAuth.instance.currentUser != null) {
      final token = await FirebaseAuth.instance.currentUser?.getIdToken();
      if (token != null) {
        options.headers['Authorization'] = "Bearer $token";
      }
    }
    return handler.next(options);
  }
}

final dioClient = Dio(BaseOptions(baseUrl: baseUrl))
      ..interceptors.addAll([
        FirebaseBearerInterceptor(),
      ]);

Spring boot 로 API 서버 구현하기

Spring boot 프로젝트를 생성합니다

oauth2-resource-server, security, web 모듈이 필요합니다

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-web")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.security:spring-security-test")
}

issuer-uri 를 Firebase 프로젝트 ID 로 설정합니다.

spring.security.oauth2.resourceserver.jwt.issuer-uri=https://securetoken.google.com/<Firebase Project ID>

프로젝트 ID 는 Firebase Console 에 프로젝트 설정 > 일반 > 프로젝트 ID 섹션에서 찾을 수 있습니다.

API 구성

이제 유저정보 조회 및 게시글 조회 하는 기능을 만들어 보겠습니다

MyAwesomeController.kt

package org.example.myauthappapi

import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class MyAwesomeController {

    @GetMapping(value = ["/me"])
    fun me(authentication: Authentication): Authentication {
        return authentication
    }

    @GetMapping(value = ["/post"])
    fun post(): String {
        return "my awesome post"
    }
}

이제 SecurityFilter 를 설정합니다.

SecurityConfig.kt

package org.example.myauthappapi

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.DefaultSecurityFilterChain

@EnableMethodSecurity
@Configuration
class SecurityConfig {

    @Bean
    fun securityFilterChain(http: HttpSecurity): DefaultSecurityFilterChain? {
        http
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers("/me").authenticated()
                    .anyRequest().permitAll()
            }
                        // resource server 활성화
            .oauth2ResourceServer { oauth -> oauth.jwt { } }
        return http.build()
    }
}

이제 /me/post 를 각각 호출 해보세요

/me 는 401 오류가 나오면서 요청에 실패하는 걸 볼 수 있습니다

curl http://localhost:8080/post -i
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 15
Date: Thu, 08 Feb 2024 13:57:18 GMT
curl http://localhost:8080/me -i
HTTP/1.1 401
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Set-Cookie: JSESSIONID=3AA95483DCCF2EFCDC9A112A7678FCCB; Path=/; HttpOnly
WWW-Authenticate: Bearer
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Thu, 08 Feb 2024 14:14:56 GMT

API 보호 테스트 해보기

Firebase Console 에서 Signin method 의 email 을 활성화 하고, 테스트 계정을 생성하세요

저는 test@email.com / 123456 으로 생성했습니다.

https://tech.cocho.io/firebaseauth-flutter-spring-1.png

이제 idToken 을 받아오겠습니다.

curl -XPOST 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=[API_KEY]' \
-H 'Content-Type: application/json' \
-d '{
    "email": "test@email.com",
    "password": "123456",
    "returnSecureToken": true
}'
  • API_KEY 는 Firebase Console 의 웹 API 키 입니다

위처럼 호출하면 아래와 비슷하게 나오는 것을 확인 할 수 있습니다. idToken 항목을 복사 해 주세요

{
  "kind": "identitytoolkit#VerifyPasswordResponse",
  "localId": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
  "email": "test@email.com",
  "displayName": "",
  "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjUzZWFiMDBhNzc5MTk3Yzc0MWQ2NjJmY2EzODE1OGJkN2JlNGEyY2MiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbXlhdXRoYXBwLTJlYWFkIiwiYXVkIjoibXlhdXRoYXBwLTJlYWFkIiwiYXV0aF90aW1lIjoxNzA3NDAxNDE0LCJ1c2VyX2lkIjoiQW1VckRtajRWemdrSUtLNG0xS1FDSkxZamhnMiIsInN1YiI6IkFtVXJEbWo0Vnpna0lLSzRtMUtRQ0pMWWpoZzIiLCJpYXQiOjE3MDc0MDE0MTQsImV4cCI6MTcwNzQwNTAxNCwiZW1haWwiOiJ0ZXN0QGVtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJ0ZXN0QGVtYWlsLmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.f0zu6nYJrH3N84hcCxKSYt7M6v9g63o5tTHHDWITmZffmmw84yGdCvIImQlwU-MKIqe74IBxiFCSuFOMVMXzv7SYBP8FKE7IkWV6pyXhSuaihw-UCHVDgdpvwwx-EH2A5UuYrBOeKyP1Yb4S2Rcmlsv0AsjQ17ZwpB3qeXtck6ilLO0SnZgJrCVTI7H9LrrCOIFkShnYjVRwRWmlnqRMW00QW0PdyoSmDLpOFfdb170s6odNX3oNaOkaT3yLEDjHo3R61Muhn0Qafrg5lkwepFmTFngs7oJUgebw1kVC0mCbKuxeA2wsazFfwznfL7ARcW0Hix13Kt2FK6y7uAwzow",
  "registered": true,
  "refreshToken": "AMf-vBwEoz00YFO6K5EVurw4fmQiqyajScTkF_D1xoE7JDcXt_pIlPZTysq5FrXpr8ECDGYUs0tKpYS3a0omQWTYTfuAxn1ta3JZz_G9wN_e92sdbivM4GJ_BVpJIPyFfhwkwy3vTNItHVLLtAabrKzAAmx9LKcLugilj-WaXaNl5bEPdyVq8Hu9GMNIf56oNGUkyLjH3iMBJCM0anjJ86NilhIyuV5-NA",
  "expiresIn": "3600"
}

이제 idToken 을 우리 API 의 /me 를 호출 할 때, header 에 함께 보내보겠습니다.

curl http://localhost:8080/me \
-H "Authorization: Bearer <idToken>" | jq ''

이제 정상적으로 응답 되는것을 확인 할 수 있습니다.

{
  "authorities": [],
  "details": {
    "remoteAddress": "127.0.0.1",
    "sessionId": null
  },
  "authenticated": true,
  "principal": {
    "tokenValue": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjUzZWFiMDBhNzc5MTk3Yzc0MWQ2NjJmY2EzODE1OGJkN2JlNGEyY2MiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbXlhdXRoYXBwLTJlYWFkIiwiYXVkIjoibXlhdXRoYXBwLTJlYWFkIiwiYXV0aF90aW1lIjoxNzA3NDAxNDE0LCJ1c2VyX2lkIjoiQW1VckRtajRWemdrSUtLNG0xS1FDSkxZamhnMiIsInN1YiI6IkFtVXJEbWo0Vnpna0lLSzRtMUtRQ0pMWWpoZzIiLCJpYXQiOjE3MDc0MDE0MTQsImV4cCI6MTcwNzQwNTAxNCwiZW1haWwiOiJ0ZXN0QGVtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJ0ZXN0QGVtYWlsLmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.f0zu6nYJrH3N84hcCxKSYt7M6v9g63o5tTHHDWITmZffmmw84yGdCvIImQlwU-MKIqe74IBxiFCSuFOMVMXzv7SYBP8FKE7IkWV6pyXhSuaihw-UCHVDgdpvwwx-EH2A5UuYrBOeKyP1Yb4S2Rcmlsv0AsjQ17ZwpB3qeXtck6ilLO0SnZgJrCVTI7H9LrrCOIFkShnYjVRwRWmlnqRMW00QW0PdyoSmDLpOFfdb170s6odNX3oNaOkaT3yLEDjHo3R61Muhn0Qafrg5lkwepFmTFngs7oJUgebw1kVC0mCbKuxeA2wsazFfwznfL7ARcW0Hix13Kt2FK6y7uAwzow",
    "issuedAt": "2024-02-08T14:10:14Z",
    "expiresAt": "2024-02-08T15:10:14Z",
    "headers": {
      "kid": "53eab00a779197c741d662fca38158bd7be4a2cc",
      "typ": "JWT",
      "alg": "RS256"
    },
    "claims": {
      "aud": ["myauthapp-2eaad"],
      "sub": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
      "email_verified": false,
      "user_id": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
      "auth_time": 1707401414,
      "iss": "https://securetoken.google.com/myauthapp-2eaad",
      "exp": "2024-02-08T15:10:14Z",
      "firebase": {
        "identities": {
          "email": ["test@email.com"]
        },
        "sign_in_provider": "password"
      },
      "iat": "2024-02-08T14:10:14Z",
      "email": "test@email.com"
    },
    "subject": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
    "id": null,
    "audience": ["myauthapp-2eaad"],
    "notBefore": null,
    "issuer": "https://securetoken.google.com/myauthapp-2eaad"
  },
  "credentials": {
    "tokenValue": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjUzZWFiMDBhNzc5MTk3Yzc0MWQ2NjJmY2EzODE1OGJkN2JlNGEyY2MiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbXlhdXRoYXBwLTJlYWFkIiwiYXVkIjoibXlhdXRoYXBwLTJlYWFkIiwiYXV0aF90aW1lIjoxNzA3NDAxNDE0LCJ1c2VyX2lkIjoiQW1VckRtajRWemdrSUtLNG0xS1FDSkxZamhnMiIsInN1YiI6IkFtVXJEbWo0Vnpna0lLSzRtMUtRQ0pMWWpoZzIiLCJpYXQiOjE3MDc0MDE0MTQsImV4cCI6MTcwNzQwNTAxNCwiZW1haWwiOiJ0ZXN0QGVtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJ0ZXN0QGVtYWlsLmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.f0zu6nYJrH3N84hcCxKSYt7M6v9g63o5tTHHDWITmZffmmw84yGdCvIImQlwU-MKIqe74IBxiFCSuFOMVMXzv7SYBP8FKE7IkWV6pyXhSuaihw-UCHVDgdpvwwx-EH2A5UuYrBOeKyP1Yb4S2Rcmlsv0AsjQ17ZwpB3qeXtck6ilLO0SnZgJrCVTI7H9LrrCOIFkShnYjVRwRWmlnqRMW00QW0PdyoSmDLpOFfdb170s6odNX3oNaOkaT3yLEDjHo3R61Muhn0Qafrg5lkwepFmTFngs7oJUgebw1kVC0mCbKuxeA2wsazFfwznfL7ARcW0Hix13Kt2FK6y7uAwzow",
    "issuedAt": "2024-02-08T14:10:14Z",
    "expiresAt": "2024-02-08T15:10:14Z",
    "headers": {
      "kid": "53eab00a779197c741d662fca38158bd7be4a2cc",
      "typ": "JWT",
      "alg": "RS256"
    },
    "claims": {
      "aud": ["myauthapp-2eaad"],
      "sub": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
      "email_verified": false,
      "user_id": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
      "auth_time": 1707401414,
      "iss": "https://securetoken.google.com/myauthapp-2eaad",
      "exp": "2024-02-08T15:10:14Z",
      "firebase": {
        "identities": {
          "email": ["test@email.com"]
        },
        "sign_in_provider": "password"
      },
      "iat": "2024-02-08T14:10:14Z",
      "email": "test@email.com"
    },
    "subject": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
    "id": null,
    "audience": ["myauthapp-2eaad"],
    "notBefore": null,
    "issuer": "https://securetoken.google.com/myauthapp-2eaad"
  },
  "token": {
    "tokenValue": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjUzZWFiMDBhNzc5MTk3Yzc0MWQ2NjJmY2EzODE1OGJkN2JlNGEyY2MiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbXlhdXRoYXBwLTJlYWFkIiwiYXVkIjoibXlhdXRoYXBwLTJlYWFkIiwiYXV0aF90aW1lIjoxNzA3NDAxNDE0LCJ1c2VyX2lkIjoiQW1VckRtajRWemdrSUtLNG0xS1FDSkxZamhnMiIsInN1YiI6IkFtVXJEbWo0Vnpna0lLSzRtMUtRQ0pMWWpoZzIiLCJpYXQiOjE3MDc0MDE0MTQsImV4cCI6MTcwNzQwNTAxNCwiZW1haWwiOiJ0ZXN0QGVtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJ0ZXN0QGVtYWlsLmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.f0zu6nYJrH3N84hcCxKSYt7M6v9g63o5tTHHDWITmZffmmw84yGdCvIImQlwU-MKIqe74IBxiFCSuFOMVMXzv7SYBP8FKE7IkWV6pyXhSuaihw-UCHVDgdpvwwx-EH2A5UuYrBOeKyP1Yb4S2Rcmlsv0AsjQ17ZwpB3qeXtck6ilLO0SnZgJrCVTI7H9LrrCOIFkShnYjVRwRWmlnqRMW00QW0PdyoSmDLpOFfdb170s6odNX3oNaOkaT3yLEDjHo3R61Muhn0Qafrg5lkwepFmTFngs7oJUgebw1kVC0mCbKuxeA2wsazFfwznfL7ARcW0Hix13Kt2FK6y7uAwzow",
    "issuedAt": "2024-02-08T14:10:14Z",
    "expiresAt": "2024-02-08T15:10:14Z",
    "headers": {
      "kid": "53eab00a779197c741d662fca38158bd7be4a2cc",
      "typ": "JWT",
      "alg": "RS256"
    },
    "claims": {
      "aud": ["myauthapp-2eaad"],
      "sub": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
      "email_verified": false,
      "user_id": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
      "auth_time": 1707401414,
      "iss": "https://securetoken.google.com/myauthapp-2eaad",
      "exp": "2024-02-08T15:10:14Z",
      "firebase": {
        "identities": {
          "email": ["test@email.com"]
        },
        "sign_in_provider": "password"
      },
      "iat": "2024-02-08T14:10:14Z",
      "email": "test@email.com"
    },
    "subject": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
    "id": null,
    "audience": ["myauthapp-2eaad"],
    "notBefore": null,
    "issuer": "https://securetoken.google.com/myauthapp-2eaad"
  },
  "name": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
  "tokenAttributes": {
    "aud": ["myauthapp-2eaad"],
    "sub": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
    "email_verified": false,
    "user_id": "AmUrDmj4VzgkIKK4m1KQCJLYjhg2",
    "auth_time": 1707401414,
    "iss": "https://securetoken.google.com/myauthapp-2eaad",
    "exp": "2024-02-08T15:10:14Z",
    "firebase": {
      "identities": {
        "email": ["test@email.com"]
      },
      "sign_in_provider": "password"
    },
    "iat": "2024-02-08T14:10:14Z",
    "email": "test@email.com"
  }
}

이제 security 에서

authentication.name 으로 접근하면, Firebase uid 에 접근 할 수 있습니다.

@GetMapping(value = ["/me"])
fun me(authentication: Authentication): Authentication {
            authentication.name // my firebase uid
      return authentication
}

이제 Firebase Auth 와, 우리 백엔드 통합을 완료했습니다.

즐코딩 하세요