JWT Auth in Lucky Api
Although Lucky is fantastic for building complete applications, I like to build my front-end in Angular so I usually use Lucky as a JSON Api. I prefer it over Rails Api because of the type checking, separation of models from forms and queries, and the way actions and routes are organized.
One feature I usually need in a JSON api is authentication, and today we'll go over setting up JWT authentication with Lucky Api.
Starter App
To start we'll be using the lucky api demo app which has User
and Post
models defined. Run:
git clone git@github.com:mikeeus/lucky_api_demo.gitgit checkout jwt-auth-0bin/setup
You can follow along by switching to the branches shown under the headings for each section. Or look at the finished product by checking out jwt-auth-10-complete
Dependencies
branch: jwt-auth-0
The only dependecy we'll need is a shard for jwt encoding and decoding. We can use the crystal jwt package so lets add the following to our shard.yml and run shards
.
dependencies:jwt:github: crystal-community/jwt
Begin with Tests
branch: jwt-auth-01-sign-in-test
How else would we know the app is working? Aight, in spec/blog_api_spec.cr
we'll add a describe block for authentication and our first test which will be for signing in.
Note I got AppVisitor
from Hannes Kaeufler's blog which is a great Lucky site that I use as reference.
# spec/blog_api_spec.crrequire "./spec_helper"describe App dovisitor = AppVisitor.new...describe "auth" doit "signs in valid user" do# create a user# visit sign in endpoint# check response has status: 200 and authorization header with "Bearer"endendend
We'll user Lucky's boxes to make generating test data easy. We'll also use Authentic's generate_encrypted_password
method to generate our password.
# spec/support/boxes/user_box.crclass UserBox < LuckyRecord::Boxdef initializename "Mikias"email "hello@mikias.net"encrypted_password Authentic.generate_encrypted_password("password")endend
Now we can generate a user in our test and make a post request to our sign_in endpoint using its email and password. And we'll check the response for the correct status code and Authorization header.
# spec/blog_api_spec.cr...it "signs in valid user" do# create a useruser = UserBox.new.create# visit sign in endpointvisitor.post("/sign_in", ({"sign_in:email" => user.email,"sign_in:password" => "password"}))# check response has status: 200 and authorization header with "Bearer"visitor.response.status_code.should eq 200visitor.response.headers["Authorization"].should_not be_nilend...
Now this test will fail because we don't have an action for this route or the forms to handle user creation, so let's build them.
Sign In
branch: jwt-auth-02-sign-in-form
If we generate a normal Lucky app it will come with Authentic already configured and several forms and actions will be generated for us. Currently, Lucky api configures Authentic
but doesn't generate these files so we'll need to add them ourselves and update them to fit our use case.
Form
Let's start with the SignInForm
which will be used to validate the user credentials, generate a token and return it in the Authorization
header of the response. This form will be the same as the one generated by Authentic
in new non-api apps, and we'll also need to create the form mixin FindAuthenticable
which wasn't generated.
# src/forms/mixins/find_authenticable.crmodule FindAuthenticatableprivate def find_authenticatableemail.value.try do |value|UserQuery.new.email(value).first?endendend# src/forms/sign_in_form.crclass SignInForm < LuckyRecord::VirtualForminclude Authentic::FormHelpersinclude FindAuthenticatablevirtual email : Stringvirtual password : Stringprivate def validate(user : User?)if userunless Authentic.correct_password?(user, password.value.to_s)password.add_error "is wrong"endelseemail.add_error "is not in our system"endendend
Action
branch: jwt-auth-03-complete-sign-in
Following Lucky's conventions we're going to create two actions:
lucky gen.action.api SignIn::Create
These commands will generate classes at src/actions/sign_up/create.cr
and src/actions/sign_in/create.cr
and two post routes to /sign_up
and /sign_in
.
Now we'll need a way to generate tokens from our user, we'll put this method in a GenerateToken
mixin because we'll use it in several of our actions.
# src/actions/mixins/auth/generate_token.crrequire "jwt"module GenerateTokendef generate_token(user)exp = 14.days.from_now.epochdata = ({ id: user.id, name: user.name, email: user.email }).to_spayload = { "sub" => user.id, "user" => Base64.encode(data), "exp" => exp }JWT.encode(payload, Lucky::Server.settings.secret_key_base, "HS256")endend
We also need to make our User
PasswordAuthenticatable
for it to be used with Authentic
. Optionally you can include Carbon::Emailable
and the emailable
method if you plan to send emails to your users on registration, password reset, etc.
# src/models/user.crclass User < BaseModelinclude Carbon::Emailableinclude Authentic::PasswordAuthenticatabletable :users docolumn name : Stringcolumn email : Stringcolumn encrypted_password : Stringenddef emailableCarbon::Address.new(email)endend
Now we can include GenerateToken
in our SignIn
action and use our SignInForm
to complete the authentication.
# src/actions/auth/sign_in.crclass SignIn::Create < ApiActioninclude GenerateTokenroute doSignInForm.new(params).submit do |form, user|if usercontext.response.headers.add "Authorization", generate_token(user)head 200elsehead 401endendendend
Run the specs with crystal spec
and voila! It works! :)
Sign Up
branch: jwt-auth-04-sign-up-test
I don't allow sign ups on my blog so I return head 401
for my SignIn
action but of course you may want to implement it in yours. It's going to be very similar to the SignIn
feature with some slight differences. Let's get to it.
Test
Let's begin by writing a test to create a user, making sure it returns the Authorization
header and that we can query our new user from the database.
# spec/blog_api_spec.crdescribe App do...describe "auth" do...it "creates user on sign up" dovisitor.post("/sign_up", ({"sign_up:name" => "New User","sign_up:email" => "test@email.com","sign_up:password" => "password","sign_up:password_confirmation" => "password"}))visitor.response.status_code.should eq 200visitor.response.headers["Authorization"].should_not be_nilUserQuery.new.email("test@email.com").first.should_not be_nilend...end
Form
branch: jwt-auth-05-sign-up-form
Now our SignUpForm
will need a PasswordValidations
module to check the passwords, we'll create that first.
# src/forms/mixins/password_validations.crmodule PasswordValidationsprivate def run_password_validationsvalidate_required password, password_confirmationvalidate_confirmation_of password, with: password_confirmationvalidate_size_of password, min: 6endend
With that we can build our sign up form.
# src/forms/sign_up_form.crclass SignUpForm < User::BaseForminclude PasswordValidationsfillable name, emailvirtual password : Stringvirtual password_confirmation : Stringdef preparevalidate_uniqueness_of emailrun_password_validationsAuthentic.copy_and_encrypt password, to: encrypted_passwordendend
Action
branch: jwt-auth-06-complete-sign-up
With those two things done our we can create our SignUp::Create
action which will look exactly the same as our SignIn::Create
action. Run lucky gen.action.api SignUp::Create
and fill it in:
# src/actions/sign_up/create.crclass SignUp::Create < ApiActioninclude GenerateTokenroute doSignUpForm.create(params) do |form, user|if usercontext.response.headers.add "Authorization", generate_token(user)head 200elsehead 401endendendend
Now we can run our tests and watch them pass!
Protecting Routes
Great we can sign in and sign out, but what good does that do us if we can't protect our resources based on it? Since every action in lucky inerits from ApiAction
or BrowserAction
, it's very straight forward to build our own AuthenticatedAction
that handles getting the current user from the Authorization
header and returning head 401
if it's not valid.
Test
branch: jwt-auth-07-authenticated-action-test
First let's write test to make sure our feature works as expected. Since we are creating posts with this api, lets make sure that the endpoint is protected. We'll create two specs and update an older one that will be effected by our changes.
Make sure to include the GenerateToken
module in our specs so we can mock an authenticated request.
# spec/blog_api_spec.crdescribe App doinclude GenerateToken...describe "/posts" do...it "creates post" douser = UserBox.createvisitor.post("/posts",new_post_data,{ "Authorization" => generate_token(user) })visitor.response_body["title"].should eq "New Post"endend...describe "auth" do...it "allows authenticated users to create posts" douser = UserBox.createvisitor.post("/posts",new_post_data,{ "Authorization" => generate_token(user) })visitor.response_body["title"].should eq new_post_data["post:title"]endit "rejects unauthenticated requests to protected actions" dovisitor.post("/posts", new_post_data)visitor.response.status_code.should eq 401endendend
Now our tests will definitely be failing so lets build our AuthenticatedAction
to make them pass.
AuthenticatedAction
In order to do so we'll need a way to get the user from the token, so lets create a mixin called UserFromToken
to do just that.
Note I chose to use mixins for generating and parsing tokens but you can also include these methods directly in the user model.
# src/actions/mixins/user_from_token.crmodule UserFromTokendef user_from_token(token : String)payload, _header = JWT.decode(token, Lucky::Server.settings.secret_key_base, "HS256")UserQuery.new.find(payload["sub"].to_s)endend
Now we can use that in our AuthenticatedAction
class.
# src/actions/authenticated_action.crabstract class AuthenticatedAction < ApiActioninclude UserFromTokenbefore require_current_usergetter current_user : User? = nilprivate def require_current_usertoken = context.request.headers["Authorization"]?if token.nil?head 401else@current_user = user_from_token(token)endif @current_user.nil?head 401elsecontinueendrescue JWT::ExpiredSignatureErrorhead 401enddef current_user@current_user.not_nil!endend
So what's happening here? We use a callback before
to run require_current_user
before the action is called. In that method we get the user from the Authorization
token and set it to the current_user
getter. If there is no token, if the user doesn't exist or if the token is expired (raises JWT::ExpiredSignatureError
) we return 401
.
We also add a current_user
method to alias our nilable getter for convenience in our actions.
Protect Actions
branch: jwt-auth-09-complete-authenticated-action
Now we can use it in our Posts::Create
action.
class Posts::Create < AuthenticatedAction # changed thisroute dopost = PostForm.create!(params, author: current_user) # and thisjson Posts::ShowSerializer.new(post), Status::Createdendend
Now we can run our specs... and BOOM! Protected.
That's whats up.
Join Us
I hope you enjoyed this tutorial and found it useful. Join us on the Lucky gitter channel to stay up to date on the framework or checkout the docs for more information on how to bring your app idea to life with Lucky.