Next, let's tackle the other half of authentication: signing in and signing out. To do this, we'll create a new SessionController, because authentication creates and destroys a "session" for the user, so our create method will display the login form, store will authenticate the user and create their session, and destroy will log the user out. Again, following the resourceful naming convention.
node ace make:controller session create store destroy # DONE: create app/controllers/session_controller.tsCopied!
That'll give us a stubbed controller with three empty methods.
import type { HttpContext } from '@adonisjs/core/http' export default class SessionController { async create({}: HttpContext) {} async store({}: HttpContext) {} async destroy({}: HttpContext) {} }Copied!
- app
- controllers
- session_controller.ts
Displaying The Login Form
Starting with the most straightforward, create just needs to render our login page.
import type { HttpContext } from '@adonisjs/core/http' export default class SessionController { async create({ view }: HttpContext) { return view.render('pages/auth/login') } async store({}: HttpContext) {} async destroy({}: HttpContext) {} }Copied!
- app
- controllers
- session_controller.ts
Remember, in the first lesson of this module, we left the pages from the starter kit intact since they didn't include anything we hadn't already discussed. So, that is already good to go.
Authenticating The User
For our store method, we'll start by pulling email and password out of the request body.
import type { HttpContext } from '@adonisjs/core/http' export default class SessionController { async create({ view }: HttpContext) { return view.render('pages/auth/login') } async store({ request, auth, response }: HttpContext) { const { email, password } = request.all() } async destroy({}: HttpContext) {} }Copied!
- app
- controllers
- session_controller.ts
Now, instead of manually looking up the user and comparing passwords ourselves, our User model provides a verifyCredentials static method that handles all of that for us. Under the hood, it finds the user by their email, then uses the hash service to verify that the provided password matches the stored hash.
import User from '#models/user' import type { HttpContext } from '@adonisjs/core/http' export default class SessionController { async create({ view }: HttpContext) { return view.render('pages/auth/login') } async store({ request, auth, response }: HttpContext) { const { email, password } = request.all() const user = await User.verifyCredentials(email, password) } async destroy({}: HttpContext) {} }Copied!
- app
- controllers
- session_controller.ts
Importantly, if either the email doesn't match a user in our database or the password is incorrect, verifyCredentials will throw an InvalidCredentialsException. This is deliberately vague. Rather than telling the user "no account with that email exists" or "that password is wrong", it simply says the credentials are invalid.
This is a security best practice. If we revealed which part of the credentials was wrong, we'd be confirming which emails have accounts, which an attacker could use to narrow their targets.
AdonisJS's exception handler picks up InvalidCredentialsException and, for HTML form submissions, redirects the user back with a flash message, similar to how validation errors are handled automatically.
If verifyCredentials succeeds, we get back the user's User model instance, and we can log them in exactly as we did after sign up.
import User from '#models/user' import type { HttpContext } from '@adonisjs/core/http' export default class SessionController { async create({ view }: HttpContext) { return view.render('pages/auth/login') } async store({ request, auth, response }: HttpContext) { const { email, password } = request.all() const user = await User.verifyCredentials(email, password) await auth.use('web').login(user) return response.redirect().toRoute('home') } async destroy({}: HttpContext) {} }Copied!
- app
- controllers
- session_controller.ts
Signing Out
Signing out is refreshingly simple. We tell auth to log the user out, then redirect them somewhere. We'll send them back to the login page.
import User from '#models/user' import type { HttpContext } from '@adonisjs/core/http' export default class SessionController { async create({ view }: HttpContext) { return view.render('pages/auth/login') } async store({ request, auth, response }: HttpContext) { const { email, password } = request.all() const user = await User.verifyCredentials(email, password) await auth.use('web').login(user) return response.redirect().toRoute('home') } async destroy({ auth, response }: HttpContext) { await auth.use('web').logout() return response.redirect().toRoute('session.create') } }Copied!
- app
- controllers
- session_controller.ts
Registering Routes & Middleware
With our controller sorted, let's register our routes. We'll also reach for our guest and auth named middleware from start/kernel.ts here, which is where the starter kit has them preconfigured for us.
We want to group the signup and login routes under the guest middleware. The idea is that an already-authenticated user has no business visiting the login or signup pages, so if they try, the middleware redirects them home.
import { controllers } from '#generated/controllers' import { middleware } from '#start/kernel' import router from '@adonisjs/core/services/router' router.where('id', router.matchers.number()) router.on('/').render('pages/home').as('home') router.on('/terms').render('pages/terms') router .resource('challenges', controllers.Challenges) .use(['index', 'edit', 'create'], middleware.requestLogger()) router .group(() => { router.get('/signup', [controllers.NewAccount, 'create']) router.post('/signup', [controllers.NewAccount, 'store']) router.get('/login', [controllers.Session, 'create']) router.post('/login', [controllers.Session, 'store']) }) .use(middleware.guest())Copied!
- start
- routes.ts
The guest middleware checks whether the user making the request is already authenticated. If they are, it reflashes their existing session data and redirects them to the home page.
Then we add a separate group for the logout route protected by the auth middleware.
import { controllers } from '#generated/controllers' import { middleware } from '#start/kernel' import router from '@adonisjs/core/services/router' router.where('id', router.matchers.number()) router.on('/').render('pages/home').as('home') router.on('/terms').render('pages/terms') router .resource('challenges', controllers.Challenges) .use(['index', 'edit', 'create'], middleware.requestLogger()) router .group(() => { router.get('/signup', [controllers.NewAccount, 'create']) router.post('/signup', [controllers.NewAccount, 'store']) router.get('/login', [controllers.Session, 'create']) router.post('/login', [controllers.Session, 'store']) }) .use(middleware.guest()) router .group(() => { router.post('/logout', [controllers.Session, 'destroy']) }) .use(middleware.auth())Copied!
- start
- routes.ts
The auth middleware checks that the user is authenticated before letting the request through. If they're not, it redirects them to /login instead. Note that the auth middleware isn't limited to just the logout route; you can wrap any routes you want to protect behind it. For example, if you only want signed-in users to create challenges, which we do, you could add the auth middleware to the create, store, edit, update, and delete endpoints of our challenges resource.
Completing The Header
Last thing! Back in our header, we can now safely fill in that {{-- TODO --}} we left behind two lessons ago. Our session routes are registered, so we won't get any route not found errors.
<header> <div> <div> @link({ route: 'home', text: 'Home' }) {{-- svg ... --}} @end </div> <div> <nav> @if(auth.user) @!avatar({ initials: auth.user.initials }) @form({ route: 'session.destroy', method: 'POST' }) @!button({ text: 'Logout' }) @end @else @!link({ route: 'new_account.create', text: 'Signup' }) @!link({ route: 'session.create', text: 'Login' }) @end </nav> </div> </div> </header>Copied!
- resources
- views
- partials
- header.edge
When the user is authenticated, we show their avatar alongside a logout button. The logout is wrapped in a @form that POSTs to session.destroy rather than using a plain link, and that's intentional. Links can be pre-fetched by browsers, meaning a user could be logged out simply because a page eagerly loaded the link in the background. Using a form and a POST request prevents that, and also protects against CSRF-based forced logouts.
When no user is authenticated, we show links to both signup and login.
With that, authentication is fully wired up! Head to /login in your browser, enter the credentials of the account you signed up with, and you should land back on the home page with your initials showing in the header. Hit logout, and you should be sent right back to /login.
Next, we'll require and attach an authenticated user when creating challenges.