꿈꾸는 시스템 디자이너

Flutter 강좌 - [Firebase] Firestore 사용법 #3 | 보안 규칙(security rules)의 이해 본문

Development/Flutter

Flutter 강좌 - [Firebase] Firestore 사용법 #3 | 보안 규칙(security rules)의 이해

독행소년 2019. 11. 15. 15:08

Flutter Code Examples 강좌를 추천합니다.

  • 제 블로그에서 Flutter Code Examples 프로젝트를 시작합니다.
  • Flutter의 다양한 예제를 소스코드와 실행화면으로 제공합니다.
  • 또한 모든 예제는 Flutter Code Examples 앱을 통해 테스트 가능합니다.

Flutter Code Examples 강좌로 메뉴로 이동

Flutter Code Examples 강좌 목록 페이지로 이동

Flutter Code Examples 앱 설치 | Google Play Store로 이동

 

Flutter Code Examples - Google Play 앱

Are you a beginner at Flutter? Check out the various features of Flutter through the demo. Source code for all demos is also provided.

play.google.com


 

Flutter 강좌 시즌2 목록 : https://here4you.tistory.com/149

 

이번 강좌에서는 Firestore의 보안 규칙을 설정하는 방법에 대해서 알아본다.

보안 규칙이란 지난 강좌에서 살펴본 데이터의 CRUD의 허용 규칙을 설정하는 것이다.

그럼 보안 규칙을 왜 설정해야할까?

만약 보안규칙이 없다면 누구나 데이터베이스를 읽고 쓸 수 있다는 것을 의미한다. 누군가 악의적으로 내 데이터베이스의 데이터를 읽어가거나 허용되지 않는 데이터를 쓰거나 삭제할 수도 있다.

그럼 어떻게 이러한 행동을 막을까?

여러 방법이 있겠지만 허가된 서비스 가입자만이 데이터베이스에 접근하여 권한에 맞는 데이터만을 읽거나 쓸 수 있도록 하면 된다.

 

Firestore의 보안규칙에 대한 자세한 문서는 Firebase 공식문서를 참고한다.

https://firebase.google.com/docs/rules/basics?hl=ko

 

기본 보안 규칙  |  Firebase

Firebase 보안 규칙을 사용하면 저장된 데이터에 대한 액세스를 제어할 수 있습니다. 유연한 규칙 구문을 사용하면 전체 데이터베이스에 대한 모든 쓰기 작업부터 특정 문서에 대한 작업까지 어떠한 상황에 맞는 규칙이라도 작성할 수 있습니다. 이 가이드에서는 앱을 설정하고 데이터를 보호할 때 구현하려는 몇 가지 기본적인 사용 사례를 설명합니다. 하지만 규칙 작성에 앞서 작성 언어와 동작에 대해 더 알고 싶을 수 있습니다. 규칙을 보고 업데이트하려면 Fire

firebase.google.com

 

우선 Firebase 콘솔에 접속하여 Database(Firestore) 메뉴로 진입한 후 규칙 메뉴로 이동한다.

 

첫 번째 Firestore 강좌에서 데이터베이스를 테스트 모드로 생성했다. 그리고 그 규칙은 다음과 같이 자동 생성되었다.

  • 한시적 허용 모드
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.time < timestamp.date(2019, 12, 14);
    }
  }
}

모든 문서를 대상으로 request.time의 timestamp값이 2019년 12월 14 이하일 경우, 모든 read와 write를 허용한다는 뜻이다.

이 경우 12월 14일 이전까지는 본인의 Firestore의 접속 URL를 통해 요청되는 모든 read와 write가 허용된다. 그리고 14일 이후에는 모든 요청을 거부하는 잠금 모드가 되어 버린다.

 

  • 잠금 모드
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

모든 사용자의 읽기/쓰기 액세스를 거부하게 된다. 당연히 서비스가 되지 않는다.

 

  • 인증된 모든 사용자 허용 모드
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.auth.uid != null;
    }
  }
}

로그인한 모든 사용자에게 읽기/쓰기를 허용하는 모드다. auth.uid 값은 Firebase Auth를 통해 로그인된 사용자의 uid를 의미하며 Firestore로 전달되는 모든 요청에 자동으로 주입된다. 만약 로그인되지 않은 사용자라면 uid 값이 null 이므로 액세스가 거부되는 방식이다.

하지만 이 방법도 안전하다고 할 수는 없다. 앱에 로그인만 한다면 모든 문서를 읽고/쓰고/삭제까지 가능하기 때문에 권장하는 방법은 아니다.

 

  • 공개 및 비공개 액세스 혼합
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Allow public read access, but only content owners can write
    match /some_collection/{document} {
      allow read: if true
      allow create: if request.auth.uid == request.resource.data.author_uid;
      allow update, delete: if request.auth.uid == resource.data.author_uid;
    }
  }
}

특정 컬랙션의 읽기 쓰기 작업의 권한을 분리해서 설정한 예이다.

some_collection이라는 컬렉션에 대하셔 규칙을 부여하는데,

읽기(read)는 어느 누구든 심지어 로그인되지 않았어도 가능

쓰기(create)는 요청 메시지의 auth.uid 값과 생성할 문서(data)의 author_uid 값이 같을 때만 가능

갱신(update)과 삭제(delete)는 요청 메시지의 auth.uid 값과 저장된 문서(data)의 author_uid 값이 같을 때만 가능

 

여기서 좀 더 세밀하게 정리해 보자.

auth.uid는 Firebase Auth를 통해 로그인된 사용자의 uid 값이며 Firestore로 요청 메시지가 전달될 때 자동으로 주입된다. 그럼 request.auth.uid란 그 요청 메시지에 주입된 auth.uid를 의미한다.

resource.data.author_uid란 문서 데이터 속의 author_uid 필드의 값을 의미한다. 그리고 이 author_uid는 플랫폼에서 자동 주입해주는 것이 아니다. 개발자가 직접 주입해야 함을 기억하자.

쓰기 작업에서의 request.auth.uid는 플랫폼에 의해 요청 메시지에 자동 주입된 uid를 의미하고, request.resource.data.author_uid는 사용자에 의해 요청메시지에 수동으로 주입한 uid를 의미한다. 그리고 이 두 값이 동일하면 해당 uid를 가지는 사용자가 정당하게 쓰기 작업을 요청한 것으로 간주하고 쓰기를 허용한다는 것이다.

그럼 갱신과 삭제의 경우는 이미 Firestore에 저장된 문서를 갱신하거나 삭제한 것이므로, 사용자에 의해 요청메시지에 수동으로 주입한 uid가 request.auth.uid와 같다고 허용해서는 안된다. Firestore에 저장된 문서의 author_uid를 의미하는 resource.data.author_uid가 같을 때 갱신/삭제를 허용한다는 것이다.

 

이를 바탕으로 실제 보안 규칙을 다음과 같이 수정해보자.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // match /{document=**} {
    //   allow read, write: if request.time < timestamp.date(2019, 12, 14);
    // }
    match /FirstDemo/{document} {
      allow read: if request.auth.uid != null;
      allow create: if request.auth.uid == request.resource.data.author_uid;
      allow update, delete: if request.auth.uid == resource.data.author_uid;
    }
  }
}

지난 강좌에서 생성한 FirstDemo 컬렉션을 대상으로 규칙을 부여했고, 읽기 작업의 경우 로그인한 사용자만을 대상으로 허용하도록 수정했다.

 

앱을 통해 테스트를 해보자.

지난 강좌에서 구현한 코드를 다음과 같이 수정한다.

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:provider/provider.dart';
import 'package:flutter_firebase/firebase_provider.dart';

FirestoreFirstDemoState pageState;

class FirestoreFirstDemo extends StatefulWidget {
  @override
  FirestoreFirstDemoState createState() {
    pageState = FirestoreFirstDemoState();
    return pageState;
  }
}

class FirestoreFirstDemoState extends State<FirestoreFirstDemo> {
  FirebaseProvider fp;
  final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();

  // 컬렉션명
  final String colName = "FirstDemo";

  // 필드명
  final String fnName = "name";
  final String fnDescription = "description";
  final String fnDatetime = "datetime";
  final String fnAuthor_uid = "author_uid";

  TextEditingController _newNameCon = TextEditingController();
  TextEditingController _newDescCon = TextEditingController();
  TextEditingController _undNameCon = TextEditingController();
  TextEditingController _undDescCon = TextEditingController();

  @override
  Widget build(BuildContext context) {
    fp = Provider.of<FirebaseProvider>(context);

    return Scaffold(
      key: _scaffoldKey,
      appBar: AppBar(title: Text("Firestore First Demo")),
      body: Column(
        children: <Widget>[
          Container(
            child: ListTile(
                title: Text("Auth UID"),
                subtitle: Text(
                    (fp.getUser() != null) ? fp.getUser().displayName : "")),
          ),
          Expanded(
            child: StreamBuilder<QuerySnapshot>(
              stream: Firestore.instance
                  .collection(colName)
                  .orderBy(fnDatetime, descending: true)
                  .snapshots(),
              builder: (BuildContext context,
                  AsyncSnapshot<QuerySnapshot> snapshot) {
                if (snapshot.hasError) return Text("Error: ${snapshot.error}");
                switch (snapshot.connectionState) {
                  case ConnectionState.waiting:
                    return Text("Loading...");
                  default:
                    return ListView(
                      children: snapshot.data.documents
                          .map((DocumentSnapshot document) {
                        Timestamp ts = document[fnDatetime];
                        String dt = timestampToStrDateTime(ts);
                        return Card(
                          elevation: 2,
                          child: InkWell(
                            // Read Document
                            onTap: () {
                              showDocument(document.documentID);
                            },
                            // Update or Delete Document
                            onLongPress: () {
                              showUpdateOrDeleteDocDialog(document);
                            },
                            child: Container(
                              padding: const EdgeInsets.all(8),
                              child: Column(
                                children: <Widget>[
                                  Row(
                                    mainAxisAlignment:
                                        MainAxisAlignment.spaceBetween,
                                    children: <Widget>[
                                      Text(
                                        document[fnName],
                                        style: TextStyle(
                                          color: Colors.blueGrey,
                                          fontSize: 17,
                                          fontWeight: FontWeight.bold,
                                        ),
                                      ),
                                      Text(
                                        dt.toString(),
                                        style:
                                            TextStyle(color: Colors.grey[600]),
                                      ),
                                    ],
                                  ),
                                  Container(
                                    alignment: Alignment.centerLeft,
                                    child: Row(
                                      mainAxisAlignment:
                                          MainAxisAlignment.spaceBetween,
                                      children: <Widget>[
                                        Text(
                                          document[fnDescription],
                                          style:
                                              TextStyle(color: Colors.black54),
                                        ),
                                        Text((document[fnAuthor_uid] != null)
                                            ? document[fnAuthor_uid]
                                            : "")
                                      ],
                                    ),
                                  )
                                ],
                              ),
                            ),
                          ),
                        );
                      }).toList(),
                    );
                }
              },
            ),
          )
        ],
      ),
      // Create Document
      floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add), onPressed: showCreateDocDialog),
    );
  }

  /// Firestore CRUD Logic

  // 문서 생성 (Create)
  void createDoc(String name, String description) {
    Firestore.instance.collection(colName).add({
      fnName: name,
      fnDescription: description,
      fnDatetime: Timestamp.now(),
      fnAuthor_uid: (fp.getUser() != null) ? fp.getUser().uid : null
    });
  }

  // 문서 조회 (Read)
  void showDocument(String documentID) {
    Firestore.instance
        .collection(colName)
        .document(documentID)
        .get()
        .then((doc) {
      showReadDocSnackBar(doc);
    });
  }

  // 문서 갱신 (Update)
  void updateDoc(String docID, String name, String description) {
    Firestore.instance.collection(colName).document(docID).updateData({
      fnName: name,
      fnDescription: description,
    });
  }

  // 문서 삭제 (Delete)
  void deleteDoc(String docID) {
    Firestore.instance.collection(colName).document(docID).delete();
  }

  void showCreateDocDialog() {
    _newNameCon.text = (fp.getUser() != null) ? fp.getUser().displayName : "";
    showDialog(
      barrierDismissible: false,
      context: context,
      builder: (context) {
        return AlertDialog(
          title: Text("Create New Document"),
          content: Container(
            height: 200,
            child: Column(
              children: <Widget>[
                TextField(
                  autofocus: true,
                  decoration: InputDecoration(labelText: "Name"),
                  controller: _newNameCon,
                ),
                TextField(
                  decoration: InputDecoration(labelText: "Description"),
                  controller: _newDescCon,
                )
              ],
            ),
          ),
          actions: <Widget>[
            FlatButton(
              child: Text("Cancel"),
              onPressed: () {
                _newNameCon.clear();
                _newDescCon.clear();
                Navigator.pop(context);
              },
            ),
            FlatButton(
              child: Text("Create"),
              onPressed: () {
                if (_newDescCon.text.isNotEmpty &&
                    _newNameCon.text.isNotEmpty) {
                  createDoc(_newNameCon.text, _newDescCon.text);
                }
                _newNameCon.clear();
                _newDescCon.clear();
                Navigator.pop(context);
              },
            )
          ],
        );
      },
    );
  }

  void showReadDocSnackBar(DocumentSnapshot doc) {
    _scaffoldKey.currentState
      ..hideCurrentSnackBar()
      ..showSnackBar(
        SnackBar(
          backgroundColor: Colors.deepOrangeAccent,
          duration: Duration(seconds: 5),
          content: Text(
              "$fnName: ${doc[fnName]}\n$fnDescription: ${doc[fnDescription]}"
              "\n$fnDatetime: ${timestampToStrDateTime(doc[fnDatetime])}"),
          action: SnackBarAction(
            label: "Done",
            textColor: Colors.white,
            onPressed: () {},
          ),
        ),
      );
  }

  void showUpdateOrDeleteDocDialog(DocumentSnapshot doc) {
    _undNameCon.text = doc[fnName];
    _undDescCon.text = doc[fnDescription];
    showDialog(
      barrierDismissible: false,
      context: context,
      builder: (context) {
        return AlertDialog(
          title: Text("Update/Delete Document"),
          content: Container(
            height: 200,
            child: Column(
              children: <Widget>[
                TextField(
                  decoration: InputDecoration(labelText: "Name"),
                  controller: _undNameCon,
                ),
                TextField(
                  decoration: InputDecoration(labelText: "Description"),
                  controller: _undDescCon,
                )
              ],
            ),
          ),
          actions: <Widget>[
            FlatButton(
              child: Text("Cancel"),
              onPressed: () {
                _undNameCon.clear();
                _undDescCon.clear();
                Navigator.pop(context);
              },
            ),
            FlatButton(
              child: Text("Update"),
              onPressed: () {
                if (_undNameCon.text.isNotEmpty &&
                    _undDescCon.text.isNotEmpty) {
                  updateDoc(doc.documentID, _undNameCon.text, _undDescCon.text);
                }
                Navigator.pop(context);
              },
            ),
            FlatButton(
              child: Text("Delete"),
              onPressed: () {
                deleteDoc(doc.documentID);
                Navigator.pop(context);
              },
            )
          ],
        );
      },
    );
  }

  String timestampToStrDateTime(Timestamp ts) {
    return DateTime.fromMicrosecondsSinceEpoch(ts.microsecondsSinceEpoch)
        .toString();
  }
}

기존의 name, description, datetime만 가지던 문서의 필드에 author_uid 필드를 신규로 추가했다.

uid 값을 구하기 위한 로그인 기능도 지난 강좌에서 사용한 Provider 코드를 재활용했다.

 

로그아웃을 하고 데이터를 조회하면 다음과 같은 화면이 출력된다.

로그인한 대상에 한해서 읽기를 허용했는데 로그인하지 않았으므로 데이터의 접근이 허용되지 않기 때문이다. 로그 창에도 다음과 같이 액세스가 거부된 것을 확인할 수 있다.

Performing hot reload...
Syncing files to device LM V409N...
Reloaded 0 of 608 libraries in 216ms.
W/Firestore(31996): (19.0.0) [Firestore]: Listen for Query(FirstDemo order by -datetime, -__name__) failed: Status{code=PERMISSION_DENIED, description=Missing or insufficient permissions., cause=null}
I/System.out(31996): com.google.firebase.firestore.FirebaseFirestoreException: PERMISSION_DENIED: Missing or insufficient permissions.

 

각기 다른 기기에서 서로 다른 계정으로 로그인한 후 데이터를 추가해보자.

 

(당연히) 서로 다른 uid를 가지는 사용자들이 메시지를 쌓고 있는 모습니다.

이제 데이터를 갱신과 삭제를 시도해보자.

해당 기기(계정)에서 작성한 문서만이 갱신/삭제가 가능한 것을 확인할 수 있다.

 

이번 강좌에서는 Firestore의 보안규칙을 이용해서 읽고 쓰기의 규칙을 설정하는 방법에 대해서 알아봤다.

 

Comments