Tech: Firebase, Next.js, Google Sign in, Firebase Stripe exstension
Bug reproduction:
When login with Google
Subscribe on stripe
Stripe saves subscription data for that user in firestore
Logout
Login in with Google and old data are overide with new one, and Subscription is lost
Does anyone had similar problem?
Maybe my implementation of Sign-in is bad, here is the Google Sign in code:
const handleGoogleLogin = () => {
signInWithPopup(auth, googleProvider)
.then(async result => {
if (!result.user) return;
const { displayName, email, uid, providerData, photoURL, phoneNumber } =
result.user;
const name = splitName(displayName as string);
const providerId =
(providerData.length && providerData[0]?.providerId) || '';
const data = {
firstName: name?.firstName || '',
lastName: name?.lastName || '',
email,
photoURL,
phoneNumber,
providerId,
};
await updateUser(uid, data);
})
.catch(error => {
console.log('Google login error: ', error);
});
};
Update user function:
export const updateUser = async (uid: string, data: UpdateUserParams) => {
try {
if (!uid) {
return;
}
await setDoc(doc(firestore, 'users', uid), {
account: {
...data,
initials: `${data.firstName[0]}${data.lastName[0]}`,
},
});
} catch (error) {
console.error('Error updating user: ', error);
}
};
setDoc is overwriting the contents of the document with each sign-in. You should instead use set with merge to prevent overwriting the fields you don't want to lose, or check first if the document exists before creating it.
See also:
https://firebase.google.com/docs/firestore/manage-data/add-data#set_a_document
Difference Between Firestore Set with {merge: true} and Update
Related
I am implementing a JWT authentication in React and I've got this authentication context with register, login and logout:
function AuthProvider({children}) {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const initialize = async () => {
try {
const accessToken = localStorage.getItem('accessToken');
if (accessToken && isValidToken(accessToken)) {
setSession(accessToken);
const response = await axios.get('/api/account/my-account');
const {user} = response.data;
dispatch({
type: 'INITIALIZE',
payload: {
isAuthenticated: true,
user,
},
});
} else {
dispatch({
type: 'INITIALIZE',
payload: {
isAuthenticated: false,
user: null,
},
});
}
} catch (err) {
console.error(err);
dispatch({
type: 'INITIALIZE',
payload: {
isAuthenticated: false,
user: null,
},
});
}
};
initialize();
}, []);
const login = async (email, password) => {
const response = await axios.post('/api/account/login', {
email,
password,
});
const {accessToken, user} = response.data;
setSession(accessToken);
dispatch({
type: 'LOGIN',
payload: {
user,
},
});
};
const register = async (email, password, firstName, lastName) => {
const response = await axios.post('/api/account/register', {
email,
password,
firstName,
lastName,
});
const {accessToken, user} = response.data;
localStorage.setItem('accessToken', accessToken);
dispatch({
type: 'REGISTER',
payload: {
user,
},
});
};
const logout = async () => {
setSession(null);
dispatch({type: 'LOGOUT'});
};
return (
<AuthContext.Provider
value={{
...state,
method: 'jwt',
login,
logout,
register,
}}
>
{children}
</AuthContext.Provider>
);
}
export {AuthContext, AuthProvider};
As you can see, my API would generate an access token and would pass the user object so I can get his name and role on the next protected page.
Is this secure enough or are there better alternatives?
Thank you.
It is secure enough as long as you enforce security in the server.
Basically, whenever a user authenticates, you provide the JWT and an unencrypted payload with user information, such as identifiers like username and email as well as access rights. So far so good.
Now you are thinking: If I save this stuff in say, local storage, the user could change the stored access rights and give him/herself more rights. Well, while this could be true, it should serve for nothing because the user can only save this copy of the data, which probably controls the visibility or availability of menu items, buttons and the like according to the level of access. What should really drive the ability to perform an action is the JWT, and the user cannot alter this JWT without access to the secret or private key used to digitally sign the token.
So yes, I'd say it is secure enough. If you have a naughty user, know that said user cannot really post new data (for instance) if the access right is not digitally signed in the JWT.
Currently I have my backend set up as such on the '/register' route:
registerRouter.post('/', async (req, res) => {
// Validate submitted registration form
const { error } = registerValidation(req.body)
if(error) {
return res.status(400).send(error.details[0].message)
}
try {
// Check if email exists already
const user = await User.findOne({ email: req.body.email })
if(user) {
return res.status(400).send('Email already exists')
}
// If not, begin registering user by hashing the password
const hashedPassword = await bcrypt.hash(req.body.password, 10)
const newUser = new User({
firstName: req.body.firstName,
lastName: req.body.lastName,
email: req.body.email,
password: hashedPassword
})
const savedUser = await newUser.save()
res.send(savedUser)
} catch(error) {
res.sendStatus(500)
}
})
Using Postman I get the proper responses when I make correct/incorrect requests. But when I make requests on my frontend, if it is an incorrect request, e.g. not long enough password, missing a required field, then I just get a 400 response. How can I use the error response to, for example, display the error on-screen for the user to see?
This is my current onSubmit function for the form:
const register = async event => {
event.preventDefault()
axios
.post('/register', newUser)
.then(res => console.log(res))
.catch(err => console.log(err))
}
try to use:
axios
.post('/register', newUser)
.catch(function (error) {
console.log(error.toJSON()); // or maybe exist .toText()
});
(https://github.com/axios/axios#handling-errors)
also convert it on server side:
return res.status(400).send('Email already exists')
to
return res.status(400).send({ error: 'Email already exists' });
I am trying to create a user profile document for regular users and for merchants on Firebase. I am trying to add additional to data this document when a merchant signs up, but haven't succeeded. The difference is that merchants are supposed to have a roles array with their roles. If this is not the right approach to deal with differentiating users, I'd also be happy to hear what's best practice.
My userService file
async createUserProfileDocument(user, additionalData) {
console.log('additionalData: ', additionalData) //always undefined
if (!user) return
const userRef = this.firestore.doc(`users/${user.uid}`)
const snapshot = await userRef.get()
if (!snapshot.exists) {
const { displayName, email } = user
try {
await userRef.set({
displayName,
email,
...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 what happens when the user signs up as a merchant in the RegisterMerchant component:
onSubmit={(values, { setSubmitting }) => {
async function writeToFirebase() {
//I can't pass the 'roles' array as additionalData
userService.createUserProfileDocument(values.user, { roles: ['businessOnwer'] })
authService.createUserWithEmailAndPassword(values.user.email, values.user.password)
await merchantsPendingApprovalService.collection().add(values)
}
writeToFirebase()
I am afraid this might have something to do with onAuthStateChange, which could be running before the above and not passing any additionalData? This is in the Middleware, where I control all of the routes.
useEffect(() => {
authService.onAuthStateChanged(async function (userAuth) {
if (userAuth) {
//is the below running before the file above and not passing any additional data?
const user = await userService.createUserProfileDocument(userAuth) //this should return the already created document?
//** do logic here depending on whether user is businessOwner or not
setUserObject(user)
} else {
console.log('no one signed in')
}
})
}, [])
There is onCreate callback function which is invoked when user is authenticated.
Here's how you could implement it
const onSubmit = (values, { setSubmitting }) => {
const { user: {email, password} } = values;
const additionalData = { roles: ['businessOnwer'] };
auth.user().onCreate((user) => {
const { uid, displayName, email } = user;
this.firestore.doc(`users/${uid}`).set({
displayName,
email,
...additionalData
});
});
authService.createUserWithEmailAndPassword(email, password);
}
I am trying to upload an image to Firebase storage. The problem is that since the user has not signed up yet, I don't have their uid.
I depend on onAuthStateChanged to get the user id and upload an image to their bucket, but so far it hasn't turned out well.
const { userObject } = useContext(Context) //trying to get the uid from here
onSubmit={(values, { setSubmitting }) => {
async function writeToFirebase() {
firebaseService.auth.createUserWithEmailAndPassword(values.email, values.password)
await firebaseService.firestore.collection('businessesPendingAdminApproval').add(values)
}
writeToFirebase()
async function sendToFirebaseImageBucket(photo, uid) {
const businessRef = await firebaseService.firestore.doc(
`businessesPendingAdminApproval/${uid}`,
)
firebaseService.storage
.ref()
.child('businesses')
.child(uid)
.child('avatar-image')
.put(photo)
.then(response => response.ref.getDownloadURL())
.then(photoURL => businessRef.update({ avatarImage: photoURL })) //try to update avatarImage
}
const uid = userObject.uid //undefined, can't get uid
sendToFirebaseImageBucket(values.avatarImage, uid) //uid gets passed as undefined
}}>
The way I am setting the userObject which is where I'm trying to get the uid from.
Setting the userObject eventually works but maybe not fast enought for me to be able to pass it to a function (as in the code above).
useEffect(() => {
firebaseService.auth.onAuthStateChanged(async function (userAuth) {
if (userAuth) {
const user = await firebaseService.createUserProfileDocument(userAuth)
setUserObject(user) //set userObject which has an uid field.
} else {
console.log('no one signed in')
}
})
}, [])
Just add your image to cloud storage right after you have logged in and was able to get uid. the following code can help you, it works for me as well. put the following code inside useEffect.
const unsubscribe = auth().onAuthStateChanged(user => {
if (user.uid){
const ref = storage.ref(`images/${user.uid}`);
const task = ref.putFile(_image, { contentType: 'image/jpeg' });
task.on(firebase.storage.TaskEvent.STATE_CHANGED, snap => {
setState({ type: 'cents', value: snap.bytesTransferred / snap.totalBytes * 100 });
}, err => { console.log('Error in help:persisAppState: ', err) }, async () => {
const image = await ref.getDownloadURL();
if (image) await db.collection("imagelinks").doc(user.id).set({ image });
});
}
});
I'm working on a react app on firebase. The issue that fire store doesn't set up userdata after fire authentication although it's been successfully authenticated on Google. Please help me if you have some clues to solve that. And I can't get error texts here.
login = () => {
const provider = new firebase.auth.GoogleAuthProvider();
firebase
.auth()
.signInWithRedirect(provider)
.then(result => {
const user = result.user;
const userRef = firebase
.firestore()
.collection("users")
.doc(user.uid);
userRef.get().then(function(doc) {
if (doc.exists) {
console.log("User data:", doc.data());
} else {
// doc.data() will be undefined in this case
userRef.set({
uid: user.uid,
name: user.displayName,
photoURL: user.photoURL
});
}
});
});
this.props.history.push("/");
};
Additional
'user' is not defined.
'userRef' is not defined
I have tried the code. But user and userRef could not be defined here:
if (doc.exists) {
console.log("Doc for user " + user.uid + " already exists");
throw new Error("Doc for user " + user.uid + " already exists");
} else {
return userRef.set({
uid: user.uid,
name: user.displayName,
photoURL: user.photoURL
});
}
Additional 2
I don't know why but that would not work then on Firebase Authentication.
login = () => {
let user;
const provider = new firebase.auth.GoogleAuthProvider();
firebase
.auth()
.signInWithRedirect(provider)
.then(result => {
console.log("result", result);
// result has been skipped
Actually, the signInWithRedirect() method returns a promise with no return value (i.e. Promise<void>). Therefore doing
firebase
.auth()
.signInWithRedirect(provider)
.then(result => {...});
will not work.
You need to use the getRedirectResult() method, as explained in the doc:
Authenticates a Firebase client using a full-page redirect flow. To
handle the results and errors for this operation, refer to
firebase.auth.Auth.getRedirectResult.
Therefore, in your login function, you should just have two lines:
login = () => {
const provider = new firebase.auth.GoogleAuthProvider();
firebase
.auth()
.signInWithRedirect(provider);
}
And somewhere else in your code (I don't know exactly where as I don't know reactjs...) you need to have the following code (note how the different Promises are chained):
let user;
firebase
.auth()
.getRedirectResult()
.then(result => {
user = result.user;
const userRef = firebase
.firestore()
.collection('users')
.doc(user.uid);
return userRef.get();
})
.then(doc => {
if (doc.exists) {
console.log('Doc for user ' + user.uid + ' already exists');
throw new Error('Doc for user ' + user.uid + ' already exists');
} else {
return doc.ref.set({
uid: user.uid,
name: user.displayName,
photoURL: user.photoURL
});
}
})
.then(() => {
this.props.history.push('/');
})
.catch(error => {
console.log(error);
this.props.history.push('/errorPage');
});
Note that in case several users are able to sign-in with the same Google Account, you may need to use a Transaction when checking the non-existence of the doc at userRef.