I implemented this and it works great. I am using Python/Flask in the backend so you would have to find equivalent backend code for Node:
Backend: store the user by their public address in the database along with a nonce used for signin
The most simple schema for a User/Account is:
public_address = db.Column(db.String(80), primary_key=True, nullable=False, unique=True)
nonce = db.Column(
INTEGER(unsigned=True),
nullable=False,
default=generate_nonce,
)
Where generate nonce is a pseudorandom number generator something like:
def generate_nonce(self, length=8):
return ''.join([str(randint(0, 9)) for i in range(length)])
Frontend GETs and signs the nonce with web3
Retrieve the public address of the current user:
web3.eth.getAccounts()
.then((response) => {
const publicAddressResponse = response[0];
if (!(typeof publicAddressResponse === "undefined")) {
setPublicAddress(publicAddressResponse);
getNonce(publicAddressResponse);
}
})
.catch((e) => {
console.error(e);
});
The frontend should do a GET request to retrieve the current nonce for the public address that is trying to sign in. If the account doesn't exist yet then create it and return the nonce anyway:
GET /api/users?publicAddress=${publicAddress}
and then sign the nonce with Metamask:
web3.eth.personal.sign(`I am signing my one-time nonce: ${nonce}`, publicAddress, "test password!")
.then((signature) => {
handleAuth(publicAddress, signature)
});
Frontend then sends the signed nonce to the backend to receive a JWT
Frontend:
axios.post(props.config.serverUrl + '/sessions/', {
publicAddress: publicAddress,
signature: signature,
auth_type: 'ethereum',
})
.then((response) => {
localStorage.setItem('accessToken', response.data.access_token);
})
.catch((e) => {
console.error(e);
});
Then on the backend you authenticate that the signature came from that public address using web3 libraries and issue a JWT if authenticated. From there it is just normal session management using JWTs, which is not specifically a web3 issue:
@sessions_blueprint.route('/sessions/', methods=['POST'])
def create_session():
auth_type = request.json.get('auth_type', AuthType.EMAIL)
public_address = request.json['publicAddress']
signature = request.json['signature']
account = EthereumAccount.query.filter_by(public_address=public_address).first()
if account is None:
abort(404, 'Public address not registered.')
original_message = 'I am signing my one-time nonce: {}'.format(account.nonce)
message_hash = defunct_hash_message(text=original_message)
signer = w3.eth.account.recoverHash(message_hash, signature=signature)
if signer == public_address:
account.nonce = account.generate_nonce()
db.session.commit()
else:
abort(401, 'could not authenticate signature')
access_token = create_access_token(identity=public_address)
refresh_token = create_refresh_token(identity=public_address)
return jsonify({
'access_token': access_token,
'refresh_token': refresh_token,
}), 200
And voila! The user is now authenticated with a JWT on the client side. You can manage sessions and authorisation for routes on the backend however you normally do.
I learnt how to do this from this article: https://www.toptal.com/ethereum/one-click-login-flows-a-metamask-tutorial
web3
is no longer accessible on a page using Metamask.