Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for Finally authenticating Rails users with MetaMask
Afri
Afri

Posted on • Edited on

     

Finally authenticating Rails users with MetaMask

It's not a secret that passwords are a relic from a different century. However, modern cryptography provides us with far better means to authenticate with applications, such asEthereum's Secp256k1 public-private key pairs. This article is a complete step-by-step deep-dive to securely establish a Ruby-on-Rails user session with an Ethereum account instead of a password. In addition, it aims to explain how it's done by providing code samples and expands on the security implications. (For the impatient, the entire code is available on Github atethereum-on-rails.)

Web3 Concepts

This post comes with some technical depth and introduces mechanics that are relatively new concepts and require you to understand some context. However, if you already know whatWeb3 is, scroll down to the next section.

Web3 is a relatively new term that introduces us to a new generation of web applications after Web 1.0 and 2.0. It's beyond the scope of this article to explain the concepts of Web3. However, it's essential to understand that web components and services are no longer hosted on servers. Instead, web applications embed content from decentralized storage solutions, such as IPFS, or consensus protocols, such as Ethereum.

Notably, there are different ways to integrate such components in web applications. However, since the most prominent way to access the web is aweb browser, most Web3 content can be easily accessed throughbrowser extensions. For example, data hosted on IPFS can be retrieved through local or remote nodes using an extension calledIPFS Companion. In addition, for blockchains such as Ethereum, there are extensions likeMetaMask.

The benefit of such an Ethereum extension is the different ways of accessing blockchain states and the ability for users to manage their Ethereum accounts. And this is what we will utilize for this tutorial: an Ethereum account in a MetaMask browser extension connecting to your Ruby-on-Rails web application to authenticate a user session securely.

Authentication Process Overview

Before diving in and creating a new Rails app, let's take a look at the components we'll need throughout the tutorial.

  1. We need to create a user model that includes fields for the Ethereum address of the user and a random nonce that the user will sign later during authentication for security reasons.
  2. We'll create an API endpoint that allows fetching the random nonce for a user's Ethereum address from the backend to be available for signing in the frontend.
  3. In the browser, we'll generate a custom message containing the website title, the user's nonce, and a current timestamp that the user has to sign with their browser extension using their Ethereum account.
  4. All these bits, the signature, the message, and the user's account are cryptographically verified in the Rails backend.
  5. If this succeeds, we'll create a new authenticated user session and rotate the user's nonce to prevent signature spoofing for future logins.

Let's get started.

Rails' User Model

We'll use a fresh Rails 7 installation without additional modules or custom functionality. Just install Rails and get a new instance according to the docs.

rails new myappcdmyapp
Enter fullscreen modeExit fullscreen mode

Create anapp/models/user.rb first, which will define the bare minimum required for our user model.

classUser<ApplicationRecordvalidates:eth_address,presence:true,uniqueness:truevalidates:eth_nonce,presence:true,uniqueness:truevalidates:username,presence:true,uniqueness:trueend
Enter fullscreen modeExit fullscreen mode

Note that we no longer care about passwords, email addresses, or other fields. Of course, you may add any arbitrary field you like, but these three fields are essential for an Ethereum authentication:

  • The username is a human-friendly string allowing users to identify themself with a nym.
  • The user's Ethereum account address is to authenticate with your application.
  • The nonce is a random secret in theuser database schema used to prevent signature spoofing (more on that later).

User Controller#create

The controllers are powerful Rails tools to handle your routes and application logic. Here, we will implement creating new user accounts with an Ethereum address inapp/controllers/users_controller.rb.

require"eth"defcreate# only proceed with pretty namesif@userand@user.usernameand@user.username.size>0# create random nonce@user.eth_nonce=SecureRandom.uuid# only proceed with eth addressif@user.eth_address# make sure the eth address is validifEth::Address.new(@user.eth_address).valid?# save to databaseif@user.save# if user is created, congratulations, send them to loginredirect_tologin_path,notice:"Successfully created an account, you may now log in."endendendendend
Enter fullscreen modeExit fullscreen mode

TheUsers controller is solely used for creating new users.

  • It generates an initial random nonce withSecureRandom.uuid.
  • It ensures the user picks a name.
  • It takes theeth_address from the sign-up view (more on that later).
  • It guarantees theeth_address is a valid Ethereum address.
  • It creates a newuser and saves it to the database with the given attributes.

We are using theeth gem to validate the address field.

Be aware that we do not require any signature to reduce complexity and increase the accessibility of this tutorial. It is, however, strongly recommended to unify the login and sign-up process to prevent unnecessary spam in theuser database, i.e., if a user with the given address does not exist, create it.

Connecting to MetaMask

We already taught our Rails backend what a User object looks like (model) and how to handle logic (controller). However, two components are missing to make this work: a new-user view rendering the sign-up form and some JavaScript to manage the frontend logic.

For the sign-up form, add aform_for @user to theapp/views/users/new.html.erb view.

<%=form_for@user,url:signup_pathdo|form|%><%=form.label"Name"%><%=form.text_field:username%><br/><%=form.text_field:eth_address,readonly:true,class:"eth_address"%><br/><%end%><buttonclass="eth_connect">Sign-up with Ethereum</button><%=javascript_pack_tag"users_new"%>
Enter fullscreen modeExit fullscreen mode

We'll allow the user to fill in the:username field but make the:eth_address field read-only because this will be filled in by the browser extension. We could even add some CSS to hide it.

Lastly, theeth_connect button triggers the JavaScript to connect to MetaMask and query the user's Ethereum account. But, first, let's take a look atapp/javascript/packs/users_new.js.

// the button to connect to an ethereum walletconstbuttonEthConnect=document.querySelector('button.eth_connect');// the read-only eth address field, we process that automaticallyconstformInputEthAddress=document.querySelector('input.eth_address');// get the user form for submission laterconstformNewUser=document.querySelector('form.new_user');// only proceed with ethereum context availableif(typeofwindow.ethereum!=='undefined'){buttonEthConnect.addEventListener('click',async()=>{// request accounts from ethereum providerconstaccounts=awaitethereum.request({method:'eth_requestAccounts'});// populate and submit formformInputEthAddress.value=accounts[0];formNewUser.submit();});}
Enter fullscreen modeExit fullscreen mode

The JavaScript contains the following logic:

  • It ensures anEthereum context is available.
  • It adds a click-event listener to the connect button.
  • It requests accounts from the available Ethereum wallet:method: 'eth_requestAccounts'
  • It adds theeth_address to the form and submits it.

Now, we have a Rails application with the basic User logic implemented. But how do we authenticate the users finally?

User Sessions

The previous sections were an introduction, preparing a Rails application to handle users with the schema we need. Now, we are getting to the core of the authentication: Users are the prerequisite; logging in a user requires aSession. Let's take a look at theapp/controllers/sessions_controller.rb.

require"eth"require"time"defcreate# users are indexed by eth address hereuser=User.find_by(eth_address:params[:eth_address])# if the user with the eth address is on record, proceedifuser.present?# if the user signed the message, proceedifparams[:eth_signature]# the message is random and has to be signed in the ethereum walletmessage=params[:eth_message]signature=params[:eth_signature]# note, we use the user address and nonce from our database, not from the formuser_address=user.eth_addressuser_nonce=user.eth_nonce# we embedded the time of the request in the signed message and make sure# it's not older than 5 minutes. expired signatures will be rejected.custom_title,request_time,signed_nonce=message.split(",")request_time=Time.at(request_time.to_f/1000.0)expiry_time=request_time+300# also make sure the parsed request_time is sane# (not nil, not 0, not off by orders of magnitude)sane_checkpoint=Time.parse"2022-01-01 00:00:00 UTC"ifrequest_timeandrequest_time>sane_checkpointandTime.now<expiry_time# enforce that the signed nonce is the one we have on recordifsigned_nonce.eql?user_nonce# recover address from signaturesignature_pubkey=Eth::Signature.personal_recovermessage,signaturesignature_address=Eth::Util.public_key_to_addresssignature_pubkey# if the recovered address matches the user address on record, proceed# (uses downcase to ignore checksum mismatch)ifuser_address.downcase.eql?signature_address.to_s.downcase# if this is true, the user is cryptographically authenticated!session[:user_id]=user.id# rotate the random nonce to prevent signature spoofinguser.eth_nonce=SecureRandom.uuiduser.save# send the logged in user back homeredirect_toroot_path,notice:"Logged in successfully!"endendendendendend
Enter fullscreen modeExit fullscreen mode

The controller does the following.

  • It finds the user byeth_address provided by the Ethereum wallet.
  • It ensures the user exists in the database by looking up the address.
  • It guarantees the user signed aneth_message to authenticate (more on that later).
  • It ensures theeth_signature field is not expired (older than five minutes).
  • It assures the signedeth_nonce matches the one in our database.
  • It recovers the public key and address from the signature.
  • It ensures the recovered address matches the address in the database.
  • It logs the user in if all the above istrue.
  • If all of the above istrue, it rotates a new nonce for future logins.

The code above, the#create-session controller, contains all security checks for the backend authentication. To successfully log in, all assessments need to pass.

Now that we have the controller, we still need a view and the frontend JavaScript logic. The view needs the form and the button inapp/views/sessions/new.html.erb.

<%=form_tag"/login",class:"new_session"do%><%=text_field_tag:eth_message,"",readonly:true,class:"eth_message"%><br/><%=text_field_tag:eth_address,"",readonly:true,class:"eth_address"%><br/><%=text_field_tag:eth_signature,"",readonly:true,class:"eth_signature"%><br/><%end%><buttonclass="eth_connect">Login with Ethereum</button><%=javascript_pack_tag"sessions_new"%>
Enter fullscreen modeExit fullscreen mode

The login form only contains three read-only fields: address, message, and signature. We can hide them and let JavaScript handle the content. The user will only interact with the button and the browser extension. So, last but not least, we'll take a look at our frontend logic inapp/javascript/packs/sessions_new.js.

// the button to connect to an ethereum walletconstbuttonEthConnect=document.querySelector('button.eth_connect');// the read-only eth fields, we process them automaticallyconstformInputEthMessage=document.querySelector('input.eth_message');constformInputEthAddress=document.querySelector('input.eth_address');constformInputEthSignature=document.querySelector('input.eth_signature');// get the new session form for submission laterconstformNewSession=document.querySelector('form.new_session');// only proceed with ethereum context availableif(typeofwindow.ethereum!=='undefined'){buttonEthConnect.addEventListener('click',async()=>{// request accounts from ethereum providerconstaccounts=awaitrequestAccounts();constetherbase=accounts[0];// sign a message with current time and nonce from databaseconstnonce=awaitgetUuidByAccount(etherbase);if(nonce){constcustomTitle="Ethereum on Rails";constrequestTime=newDate().getTime();constmessage=customTitle+","+requestTime+","+nonce;constsignature=awaitpersonalSign(etherbase,message);// populate and submit formformInputEthMessage.value=message;formInputEthAddress.value=etherbase;formInputEthSignature.value=signature;formNewSession.submit();}});}
Enter fullscreen modeExit fullscreen mode

That's a lot to digest, so let's look at what the script does, step by step.

  • It, again, ensures an Ethereum context is available.
  • It adds a click-event listener to theeth_connect button.
  • It requests accounts from the available Ethereum wallet:method: 'eth_requestAccounts'
  • It requests the nonce belonging to the account from the API/v1 (more on that later).
  • It generates a message containing the site's title, the request time, and the nonce from the API/v1.
  • It requests the user to sign the message:method: 'personal_sign', params: [ message, account ]
  • It populates the form with address, message, and signature and submits it.

Putting aside the API/v1 (for now), we have everything in place: The Rails application crafts a custom message containing a random nonce and a timestamp. Then, the frontend requests the user to sign the payload with their Ethereum account. The following snippet shows the relevant JavaScript for requesting accounts and signing the message.

// request ethereum wallet access and approved accounts[]asyncfunctionrequestAccounts(){constaccounts=awaitethereum.request({method:'eth_requestAccounts'});returnaccounts;}// request ethereum signature for message from accountasyncfunctionpersonalSign(account,message){constsignature=awaitethereum.request({method:'personal_sign',params:[message,account]});returnsignature;}
Enter fullscreen modeExit fullscreen mode

Once the message is signed, both the message and the signature, along with the Ethereum account's address, get passed to the Rails backend for verification. If all backend checks succeed (see session controller above), we consider the user authenticated.

Back and forth

Let's quickly recap. We have a user model containing address, nonce, and name for every user of our Rails application. To create a user, we allow the user to pick a nym, ask the browser extension for the user's Ethereum address and roll a random nonce (here: UUID) for the user database. To authenticate, we let the user sign a message containing a custom string (here: site title), the user's nonce, and a timestamp to force the signature to expire. If the signature matches the Ethereum account and nonce on the record and is not expired, we consider the user cryptographically authenticated.

But one thing is missing. So far, both creating a user and authenticating a new session was a one-way operation, passing data from the frontend to the backend for validation. However, to sign the required nonce from the user database, we need a way for the frontend to access the user's nonce. For that, we create a public API endpoint that allows querying theeth_nonce from the user database by theeth_address key. Let's take a look atapp/controllers/api/v1/users_controller.rb.

require"eth"classApi::V1::UsersController<ApiController# creates a public API that allows fetching the user nonce by addressdefshowuser=nilresponse=nil# checks the parameter is a valid eth addressparams_address=Eth::Address.newparams[:id]ifparams_address.valid?# finds user by valid eth address (downcase to prevent checksum mismatchs)user=User.find_by(eth_address:params[:id].downcase)end# do not expose full user object; just the nonceifuseranduser.id>0response=[eth_nonce:user.eth_nonce]end# return response if found or nil in case of mismatchrenderjson:responseendend
Enter fullscreen modeExit fullscreen mode

The#show controller gets a user byeth_address from the database and returns theeth_nonce ornil if it does not exist.

  • GET/api/v1/users/${eth_account}
  • It ensures theeth_account parameter is a valid Ethereum address to filter out random requests.
  • It finds a user in the database byeth_account key.
  • It returns only theeth_nonce as JSON.
  • It returns nothing if it fails any of the above steps.

The frontend can use some JavaScript to fetch this during authentication.

// get nonce from /api/v1/users/ by accountasyncfunctiongetUuidByAccount(account){constresponse=awaitfetch("/api/v1/users/"+account);constnonceJson=awaitresponse.json();if(!nonceJson)returnnull;constuuid=nonceJson[0].eth_nonce;returnuuid;}
Enter fullscreen modeExit fullscreen mode

And that's it. So now we have all pieces in place. Run your Rails application and test it out!

bundleinstallbin/rails db:migratebin/rails server
Enter fullscreen modeExit fullscreen mode

What did I just read?

To recap, anEthereum account is a public-private key pair (very similar to SSH, OTR, or PGP keys) that can be used to authenticate a user on any web application without any need for an email, a password, or other gimmicks.

Our application identifies the user not by its name but by the public Ethereum address belonging to their account. By cryptographically signing a custom message containing a user secret and a timestamp, the user can prove that they control the Ethereum account belonging to the user on the record.

A valid, not expired signature matching the nonce and address of the user allows us to grant the user access to our Rails application securely.

Security Considerations

One might wonder, is this secure?

Generally speaking, having an Ethereum account in a browser extension is comparable with a password manager in a browser extension from an operational security standpoint. The password manager fills the login form with your email and password, whereas the Ethereum wallet shares your address and the signature you carefully approved.

From a technical perspective, it's slightly more secure as passwords can be easier compromised than signatures. For example, a website that tricks you into believing they are your bank can very well steal your bank account credentials. This deception is calledphishing, and once your email and password are compromised, malicious parties can attempt to log in to all websites where they suspect you of having the same credentials.

Phishing Ethereum signatures is also possible, but due to the very limited validity of the signature both in time and scope, it's more involved. The user nonce in the backend gets rotated with each login attempt, making a signature valid only once. By adding a timestamp to the signed message, applications can also reduce attackers' window of opportunity to just a few minutes.

Isn't there a standard for that?

There is:EIP-4361 tries to standardize the message signed by the user. Check out theSign-in with Ethereum (SIWE) project.

This article is considered educational material and does not use the SIWE-libraries to elaborate on more detailed steps and components. However, it's recommended to check out theRails SIWE examples for production.

Does this make sense? Please let me know in the comments! Thanks for reading!

Further resources

Top comments(3)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
ltfschoen profile image
Luke Schoen
  • Joined

it mentions that in the login form you can hide the field values that are generated address, custom message (including the website title, the user's nonce, and the current timestamp), and signature since JavaScript can handle the content, and that the user will only interact with the button and the browser extension, but if you did that then the user may not know what they're signing, and whilst i think it's now possible to view the custom message in the browser extension like MetaMask when they're actually signing it with their Ethereum account, it may not be clear what those values represent when they appear on the MetaMask page where they're prompted to sign, so perhaps it's better to first display and explain what the custom message contains on the frontend page itself so the users understand, or if it's possible to provide information about each part of the custom message to MetaMask when they click to login and update the MetaMask codebase so the user can toggle a view that explains more information about what parts of the custom message mean within the MetaMask signature windows prompt

CollapseExpand
 
ltfschoen profile image
Luke Schoen
  • Joined

what is a "nym"?
That word is mentioned a couple of times as it relates to the username but I don't understand what it means or whether it's an abbreviation for something

CollapseExpand
 
abigoroth profile image
Ahmad Ya'kob Ubaidullah
hi, im an experienced Ruby on Rails developer. But I need to do some CSS for now. I'm in big trouble now. :D
  • Location
    Malaysia
  • Work
    Senior software engineer at DNSVault SB
  • Joined

hi.. I'm having problem with android metamask to access this kind of setup.
android metamask will not hold the session.

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Bitcoin, Ethereum, IPFS/Filecoin, Polkadot.
  • Location
    Berlin
  • Education
    M.Sc in Coputer Graphics Systems
  • Work
    Head of Protocol at ChainSafe Systems
  • Joined

More fromAfri

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp