Updating private field with Firebase Cloud: best practice for security - javascript

I'm using Firestore with some private documents (no write). My rules are already setup for this. For example, a document could contain the credits or subscription tier for a user. I want to let the backend update these fields instead of the client, for obvious reasons. However, I was wondering, if I create a generic updatePrivateField method in Cloud functions, would it be considered best practice?
exports.updateProtectedField = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
"failed-precondition",
"Authentication Required"
);
}
const { collection, id, update } = data;
try {
await admin
.firestore()
.collection(collection)
.doc(id)
.update({
...update,
});
return { msg: "Update successful", code: 200 };
} catch (error) {
throw new functions.https.HttpsError("unknown", error.message, error);
}
});
Basically, what I am wondering is, is creating an endpoint like this considered safe? I am checking if the user is authenticated, but couldn't they just POST to the endpoint with their own login credentials and update any field in the database?
Thanks, I appreciate any help!

In case of a user can update own document.
Should set context.auth.uid as document id.
exports.updateProtectedField = functions.https.onCall(async (data, context) => {
// Check context.auth.uid
if (!context.auth || !context.auth.uid) {
throw new functions.https.HttpsError(
"failed-precondition",
"Authentication Required"
);
}
const { collection, update } = data;
// Set context.auth.uid as document id
try {
await admin
.firestore()
.collection(collection)
.doc(context.auth.uid)
.update({
...update,
});
return { msg: "Update successful", code: 200 };
} catch (error) {
throw new functions.https.HttpsError("unknown", error.message, error);
}
});
In case of a some role (ex. admin) can update a user document.
Should use Custom Claims and check it.
See https://firebase.google.com/docs/auth/admin/custom-claims
ex. Use a admin role
// Add any trigger or any condition you want.
// Set admin privilege on the user corresponding to uid.
admin.auth().setCustomUserClaims(uid, {admin: true}).then(() => {
// The new custom claims will propagate to the user's ID token the
// next time a new one is issued.
});
exports.updateProtectedField = functions.https.onCall(async (data, context) => {
// Check user is admin
if (!context.auth || !context.auth.token.admin) {
throw new functions.https.HttpsError(
"failed-precondition",
"Authentication Required"
);
}
const { collection, id, update } = data;
try {
await admin
.firestore()
.collection(collection)
.doc(id)
.update({
...update,
});
return { msg: "Update successful", code: 200 };
} catch (error) {
throw new functions.https.HttpsError("unknown", error.message, error);
}
More documents
https://firebase.google.com/docs/reference/functions/providers_https_.callablecontext#auth
https://firebase.google.com/docs/reference/admin/node/admin.auth.DecodedIdToken
https://cloud.google.com/functions/docs/concepts/functions-and-firebase
https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_the_firebase_admin_sdk
https://cloud.google.com/endpoints/docs/openapi/authenticating-users-firebase

Your Cloud Functions code allows any authenticated user to update any document. It is pretty much the equivalent of these security rules:
service cloud.firestore {
match /databases/{database}/documents {
match /{collection/{document=**} {
allow write: if request.auth != null;
}
}
}
If that is what you want to accomplish, I recommend doing so with the above security rules as it'll be both simpler and cheaper than introducing Cloud Functions to the mix.
If a user should only be able to update their own document through this (as zhoki's answer suggests by using context.auth.uid), then that'd be the equivalent of these security rules:
service cloud.firestore {
match /databases/{database}/documents {
match /{collection}/{userId} {
allow write: if request.auth != null && request.auth.uid == userId;
}
}
}
If this is the use-case you're looking for, I'd again recommend using security rules to secure it and bypass Cloud Functions for a simpler and cheaper solution.
In both of the cases above the {collection} allows the user to update documents in any collection, since that is what your Cloud Functions code also seems to do. It is much more common to limit the update to a specific collection, in which case you'd replace {collection} with just that collection name.

Related

Nuxt + Firebase FirebaseError: Missing or insufficient permissions

I am trying to read a collection of documents only if the document has the user id of the current user logged in with firebase. Here are my database rules:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /todos/{todoId} {
allow read: if isLoggedIn() && request.auth.uid == resource.data.userId;
allow create: if isLoggedIn() && request.auth.uid == request.resource.data.userId;
allow update, delete: if isLoggedIn() && request.auth.uid == resource.data.userId;
}
function isLoggedIn() {
return request.auth != null;
}
}
}
Creating documents is not a problem, but reading them. Here is my function that retrieves the data:
getData () {
this.$fire.firestore.collection('todos').get()
.then((doc) => {
if (doc.exists) {
console.log('Document data:', doc.data())
} else {
// doc.data() will be undefined in this case
console.log('No such document!')
}
}).catch((error) => {
console.log('Error getting document:', error)
})
}
It seems that the resource.data.id rule is not pointing to the todo userId and therefore I get the FirebaseError: Missing or insufficient permissions. Here is my current database structure:
Any ideas on why the rule is not working as intended?
Firebase security rules don't filter data. Instead they merely ensure that any operation you perform meets the rules you've set.
So this rule you have says that the user can only read documents that have their own UID:
allow read: if isLoggedIn() && request.auth.uid == resource.data.userId;
But then your code goes ahead and tries to read all documents:
this.$fire.firestore.collection('todos').get()
Since your rules don't allow reading all documents, the operation is rejected.
To allow the operation, ensure that your read operation matches your rules and only request documents where the UID matches:
let uid = ...
this.$fire.firestore.collection('todos').where("userId", "==", uid).get()
I already solved the issue, the method for getting the data from firestore was incomplete, the solution was in the firebase documentation.
The correct way to do the data retrieving is using the where constraint:
getData () {
this.$fire.firestore.collection('todos').where('userId', '==', this.user.uid).get()
.then((docs) => {
if (docs) {
// console.log('Document data:', docs.data())
docs.forEach((doc) => {
console.log(doc.data())
})
} else {
// doc.data() will be undefined in this case
console.log('No such document!')
}
}).catch((error) => {
console.log('Error getting document:', error)
})
}

How to fix firebase "User is not authorized" error even though user is authenticated in registration flow?

Im trying to do a variety of firebase actions in one call in a react-native app using react-native-firebase. the flow goes something like this:
create user in authentication
send image to storage
send data to firestore
During the image-storage phase, the imgRef.putFile() function errors out saying the user isn't authorized. However im using createUserWithEmailAndPassword() (which authenticates a user in on completion) and then using the returned credential to do the rest of the work, image storage and firestore creations.
firebase storage rules are set to allow only authenticated users. here's the ruleset:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
allow read, write: if request.auth != null;
}
}
}
Also, I have enabled anonymous signin in authentication methods.
here's the initial action up until the error point:
return (dispatch) => {
dispatch({ type: types.REGISTER_USER });
console.log('starting registration process...');
firebase
.firestore()
.collection('users')
.where('username', '==', username)
.get()
.then((querySnapshot) => {
console.log(querySnapshot);
if (querySnapshot.empty !== true) {
registrationFail(dispatch, 'Username already taken. Try again.');
console.log("Registrant's username already exists");
} else {
console.log('Registrants username is unique');
firebase
.auth()
.createUserWithEmailAndPassword(email, pass)
.then((userCredential) => {
uploadImg(dispatch, img, userCredential.user.uid)
here's the uploadImg() function:
const uploadImg = async (dispatch, uri, uid) => {
console.log('Starting image upload...');
dispatch({ type: types.UPLOAD_IMG, info: 'Uploading profile image...' });
const uploadUri = Platform.OS === 'ios' ? uri.replace('file://', '') : uri;
const imgRef = firebase.storage().ref('profile-images').child(uid);
return imgRef
.putFile(uploadUri, { contentType: 'image/png' })
.then((success) => {
console.log('profile image upload successful.')
return imgRef;
})
.catch((err) => {
console.log('profile image upload failed: ' + err)
uploadImgFail(dispatch, err.message);
});
};
again, the error that logs in the console is:
profile image upload failed: Error: User is not authorized to perform the desired action.
logging the firebase.auth().currentUser right before the uploading() function returns the current user object successfully.
this security rule issue also happens with Firestore, despite my security ruleset for the given collection being:
match /databases/{database}/documents {
match /users/{uid} {
// allow new user to check phone numbers
allow read: if true
allow update, create: if request.auth != null
allow delete: if request.auth.uid == uid
}
}
This is a part of my registration flow. I collect input, send relevant data to redux action, create a user, once the user is created, I add some data to a document in firestore. this makes no sense. im referencing the documentation and it still doesn't work.
How can this be fixed?
It seems to be an issue with firebase that you have to logout() the user once before you can log in after user creation. I faced the same issue and this is my workaround:
firebase.auth().signInWithEmailAndPassword(userEmail,userPass).catch((error) => {
/*console.log('Could not log in user');*/
console.log(error);
firebase.auth().createUserWithEmailAndPassword(userEmail,userPass).catch((error) => {
/*console.log('Could not create user');*/
console.log(error);
}).then(result => {
firebase.auth().signOut().then(() => {
firebase.auth().signInWithEmailAndPassword(userEmail,userPass).then(result => {
/*console.log("here is you logged in user");*/
})
});
});
});
});

How can I differentiate between authenticated users?

I am building an app that has both merchants and clients. Merchants offer their services and clients can book services from the merchants.
They BOTH are authenticated with Firebase and are on the Authentication list you can find on the Firebase Console.
On sign up, merchants' info go to a collection called 'businesses'. Clients go on a collection called 'users'.
This is how I create a 'user' document
async createUserProfileDocument(user, additionalData) {
if (!user) return
const userRef = this.firestore.doc(`users/${user.uid}`)
const snapshot = await userRef.get()
if (!snapshot.exists) {
const { displayName, email, photoURL, providerData } = user
const createdAt = moment().format('MMMM Do YYYY, h:mm:ss a')
try {
await userRef.set({
displayName,
email,
photoURL,
createdAt,
providerData: providerData[0].providerId, //provider: 'google.com', 'password'
...additionalData,
})
} catch (error) {
console.error('error creating user: ', error)
}
}
return this.getUserDocument(user.uid)
}
async getUserDocument(uid) {
if (!uid) return null
try {
const userDocument = await this.firestore.collection('users').doc(uid).get()
return { uid, ...userDocument.data() }
} catch (error) {
console.error('error getting user document: ', error)
}
}
This is how 'users' sign up
export const Register = () => {
const history = useHistory()
async function writeToFirebase(email, password, values) { //function is called below
try {
const { user } = await firebaseService.auth.createUserWithEmailAndPassword(email, password)
firebaseService.createUserProfileDocument(user, values)
} catch (error) {
console.error('error: ', error)
}
}
//Formik's onSubmit to submit a form
function onSubmit(values, { setSubmitting }) {
values.displayName = values.user.name
writeToFirebase(values.user.email, values.user.password, values) //function call
}
This is how a 'merchant' registers. They sign up with email + password and their info from a form go to a collection called 'businesses'
firebaseService.auth.createUserWithEmailAndPassword(values.user.email, values.user.password)
await firebaseService.firestore.collection('businesses').add(values) //values from a form
Here is where I would like to be able to differentiate between 'users' and 'merchants', so that I can write some logic with the 'merchant' data. so far it only works with 'users'
useEffect(() => {
firebaseService.auth.onAuthStateChanged(async function (userAuth) {
if (userAuth) {
//**how can I find out if this userAuth is a 'merchant' (business) or 'user' (client)
const user = await firebaseService.createUserProfileDocument(userAuth)
setUsername(user.displayName)
//if (userAuth IS A MERCHANT) setUserIsMerchant(true) **what I'd like to be able to do
} else {
console.log('no one signed in')
}
})
}, [])
The recommended way for implementing a role-based access control system is to use Custom Claims.
You will combine Custom Claims (and Firebase Authentication) together with Firebase Security Rules. As explained in the doc referred to above:
The Firebase Admin SDK supports defining custom attributes on user
accounts. This provides the ability to implement various access
control strategies, including role-based access control, in Firebase
apps. These custom attributes can give users different levels of
access (roles), which are enforced in an application's security rules.
Once you'll have assigned to your users a Custom Claim corresponding to their user role (e.g. a merchant or client Claim), you will be able to:
Adapt your Security Rules according to the claims;
Get the Claim in your front-end and act accordingly (e.g. route to specific app screens/pages, display specific UI elements, etc...)
More precisely, as explained in the doc, you could do something like:
useEffect(() => {
firebaseService.auth.onAuthStateChanged(userAuth => {
if (userAuth) {
userAuth.getIdTokenResult()
.then((idTokenResult) => {
// Confirm the user is a Merchant or a Client
if (!!idTokenResult.claims.merchant) {
// Do what needs to be done for merchants
} else if (!!idTokenResult.claims.client) {
// Do what needs to be done for clients
}
} else {
console.log('no one signed in')
}
})
}, [])
You may be interested by this article which presents "How to create an Admin module for managing users access and roles" (disclaimer, I'm the author).

Creating Unique Usernames with firebase (React/Javascript) [duplicate]

This question already has answers here:
Cloud Firestore: Enforcing Unique User Names
(8 answers)
Closed 3 years ago.
What i'am attempting to do is create new users with Firebase authentication and create their own document entry within Firestore. So far everything went well until i wanted to create unique usersnames. What is the most optimal way of going about doing this? should i create an array of all the users to cross-reference upon each sign up, or perhaps create a collection dedicated to Usernames with their email attached (which you can see i did in the second call to Firebase)
Firebase auth takes in email and passwords only so thats out of the question, or at least from what i gather.
export const signInUser = async ({ name, email, password }) => {
try {
await firebase
.auth()
.createUserWithEmailAndPassword(email, password)
.then(cred => {
return firebase
.firestore()
.collection("users")
.doc(cred.user.uid)
.set({
name: name,
email: email,
friends: []
});
});
await firebase
.firestore()
.collection("usernames")
.doc(name)
.set({ username: name, email: email });
firebase.auth().currentUser.updateProfile({
displayName: name
});
return {};
} catch (error) {
switch (error.code) {
case "auth/email-already-in-use":
return {
error: "E-mail already in use."
};
case "auth/invalid-email":
return {
error: "Invalid e-mail address format."
};
case "auth/weak-password":
return {
error: "Password is too weak."
};
case "auth/too-many-requests":
return {
error: "Too many request. Try again in a minute."
};
default:
return {
error: "Check your internet connection."
};
}
}
};
I'd skip the "usernames" collection here, as you're basically creating an index, which fireStore already does for you. To check whether a name is unique, you can do:
const snapshot = await firestore.collection("users").where("name", "==", name).get();
// The name is taken if snapshot.empty is false.

How to handle multiple errors in promise chain?

I'm using AWS Amplify for authentication and Stripe for the payment to create sign up page.
PROBLEM: I can't find a way to combine validations for Email and password section(from AWS Amplify) with payment info section(from Stripe).
My current code creates a Stripe token and call API(with valid payment info) then handles the error message from userSignupRequest which takes care of email and password fields.
How do I validate the email and password with payment info then create account in AWS and Stripe?
// Stripe payment process
this.props.stripe.createToken(
{
email: this.state.email
}
).then(result => {
// PROBLEM: Form server validation from Stripe
if(result.error){
return this.setState({ errors: { errorMsg: result.error.message }, isLoading: false })
}
// if success, create customer and subscription with result.token.id
const apiName = 'NameOfAPI';
const path = '/stripe/signup';
let myInit = {
body: {
"stripeToken": result.token.id,
"email": this.state.email
}
}
API.post(apiName , path, myInit).then(reponse => {
this.props.userSignupRequest(this.state.email, this.state.password, reponse).then(user => {
this.setState({
confirmAccount: true,
isLoading: false,
userEmail: this.state.email,
errors: {}
})
this.props.history.push('/signup#confirm-account')
}).catch(err => {
// PROBLEM: Form server validation
this.setState({ errors: { errorMsg: err.message }, isLoading: false })
})
}).catch(err => {
console.log(err)
this.setState({ errors: { errorMsg: err }, isLoading: false })
});
})
It seems like we have a very similar stack. My solution was to handle everything server-side. You'll need to give your lambda functions the appropriate IAM permissions to access Cognito. The code below is a little long. I use async/await, which really cleans things up for me. You'll need to use Lambda with node 8 to use async/await though.
I validate that everything matches the right format client-side (i.e. emails are really emails, passwords are the right length). I realized the only error that could come up is an "existing user" error from Cognito. The idea is: test if the user exists before you attempt to sign the person up with Stripe. There's no way to "test" if the user's credit card is valid with Stripe. It's all or nothing. If it's valid it will go through, if not, you'll get an error. If it goes through, you can then sign up the user with Cognito, knowing you should not get an error (you've validated the email and password client-side and, you know the use doesn't already exist).
For reference, here's the aws-sdk for cognito.
const AWS = require('aws-sdk');
const cognito = new AWS.CognitoIdentityServiceProvider({
region: "region",
userPoolId: "cognito_user_pool_id",
});
module.exports.signUpUser = (payload) => {
const usernamePayload = {
UserPoolId: "cognito_user_pool_id",
Username: payload.email,
};
// I use emails for usernames.
new Promise((resolve, reject) => {
cognito.adminGetUser(usernamePayload, (error, response) => {
if (error && error.code === 'UserNotFoundException') {
resolve(false);
} else if (error) {
reject(error);
} else {
// if adminGetUser doesn't fail, it means the username exists
resolve(true);
}
});
}).then((usernameExists) => {
if (!usernameExists) {
// run stripe API stuff
// always run before sign up below to catch stripe errors
// and return those errors to client
// before you sign up the user to Cognito
// since you've already verified the user does not exist
// it would be rare for an error to come up here
// as long as you validate passwords and emails client-side
const signUpPayload = {
ClientId: "cognito_user_pool_client_id",
Username: payload.email,
Password: payload.password,
UserAttributes: [
{
Name: 'email',
Value: payload.email,
},
],
};
new Promise((resolve, reject) => {
cognito.signUp(signUpPayload, (error, response) => {
if (error) {
reject(error);
} else {
resolve(response);
}
});
}).catch((error) => {
// you should hopefully encounter no errors here
// once you get everything setup correctly
console.log(error);
})
} else {
// means username already exists, send error to client
// saying username exists
}
}).catch((error) => {
// may want to dispatch this error to client
console.log(error);
});
return null;
};

Categories

Resources