In Register & Login with JWT, the token routes learned who is calling, but the task and user routes still answer anyone. Me and Logout also start with the same lines to read the token. In this part we move that check into a middleware that guards every route that needs a login, hand the signed-in user to the handlers, and limit how fast anyone can guess a password.
A middleware is code that runs before the handler. It either passes the request on or answers it, so one login check can guard every route that needs it.
Terminal window
gorun.artisanmake:middlewareAuth
SUCCESSMiddlewarecreatedsuccessfully
It writes app/http/middleware/auth.go:
app/http/middleware/auth.go
packagemiddleware
import(
"github.com/goravel/framework/contracts/http"
)
typeAuthstruct{}
func(m *Auth)Signature()string{
return"Auth"
}
func(m *Auth)Handle(ctx http.Context){
ctx.Request().Next()
}
Signature names the middleware.
Handle runs for each request. ctx.Request().Next() passes the request on to the next middleware, or to the handler.
Fill in Handle with the Parse check from Me:
app/http/middleware/auth.go
packagemiddleware
import(
"taskflow/app/facades"
"github.com/goravel/framework/contracts/http"
)
6 collapsed lines
typeAuthstruct{}
func(m *Auth)Signature()string{
return"Auth"
}
func(m *Auth)Handle(ctx http.Context){
if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err !=nil{
ctx.Response().Json(401,map[string]any{
"error": err.Error(),
}).Abort()
return
}
ctx.Request().Next()
}
If the token is missing, changed, expired or logged out, the middleware answers 401 with the reason.
Abort stops the request there, so the handler never runs.
Protecting routes
Register and log in stay open, since nobody has a token yet. Refresh stays open too, because it must accept an expired token, which the middleware refuses. Every other API route goes into a group behind the middleware:
In requests.http, the task and user requests need the header too:
requests.http
39 collapsed lines
### Register
POST http://localhost:3000/api/v1/register
Content-Type:application/json
{
"name":"Ben",
"email":"ben@example.com",
"password":"secret123"
}
### Log in
POST http://localhost:3000/api/v1/login
Content-Type:application/json
{
"email":"ben@example.com",
"password":"secret123"
}
> {%
client.global.set("token", response.body.token);
%}
### Me
GET http://localhost:3000/api/v1/me
Authorization:Bearer {{token}}
### Refresh
POST http://localhost:3000/api/v1/refresh
Authorization:Bearer {{token}}
> {%
client.global.set("token", response.body.token);
%}
### Log out
POST http://localhost:3000/api/v1/logout
Authorization:Bearer {{token}}
### Create task
POST http://localhost:3000/api/v1/tasks
Authorization:Bearer {{token}}
Content-Type:application/json
5 collapsed lines
{
"title":"Ship v1",
"description":"The first release"
}
### Create task with bad data
POST http://localhost:3000/api/v1/tasks
Authorization:Bearer {{token}}
Content-Type:application/json
6 collapsed lines
{
"title":" ",
"assigned_to":99,
"due_at":"2020-01-01"
}
### Show task
GET http://localhost:3000/api/v1/tasks/1
Authorization:Bearer {{token}}
### List tasks
GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
Authorization:Bearer {{token}}
### List users
GET http://localhost:3000/api/v1/users
Authorization:Bearer {{token}}
### Show user
GET http://localhost:3000/api/v1/users/2
Authorization:Bearer {{token}}
### Hello
GET http://localhost:3000/hello
MoreMiddleware on every route
router.Middleware puts a middleware on some routes. A middleware for every route, like one that logs each request, goes in bootstrap/app.go with WithMiddleware. See global middleware.
Laravel also has a guest middleware, which keeps logged-in users away from the login page. An API with tokens has no such pages, so TaskFlow doesn’t need one.
One place to check the token
The middleware has parsed the token by the time Me and Logout run, so they can drop their own Parse lines. Refresh keeps its own:
{"error":"authentication token has been disabled"}
GotchaJson only builds the answer. .Abort() stops the request and sends it. Leave Abort out and the 401 is never sent, and the handler runs as if the middleware weren’t there. With no token, the task list answers and /me fails with a 500:
Terminal window
curl-ihttp://localhost:3000/api/v1/tasks
HTTP/1.1200OK
Content-Type:application/json; charset=utf-8
Content-Length:35
{"filters":{},"page":1,"status":""}
curl-ihttp://localhost:3000/api/v1/me
HTTP/1.1500InternalServerError
Content-Type:application/json; charset=utf-8
Content-Length:36
{"error":"Could not load the user."}
Nothing warns you, so every answer in a middleware needs Abort.
A token for a deleted user
Parse checks the token’s signature, its expiry and the logged-out list in Redis. It never asks Postgres, so a token keeps working after its user is gone. Register Dan, log him in, and delete him while his token is still valid:
Goravel’s usual way is to call facades.Auth(ctx).User(&user) in the handlers that need the user. That only checks the routes whose handlers remember to call it, and each call runs its own query. Loading the user in the middleware checks every protected route, and the handlers get the user without asking again.
Passing the user to handlers
ctx.WithValue attaches a value to the request, and ctx.Value reads it back by its key. A small package that owns the key means the user can only be set and read through its two functions:
app/http/authuser/authuser.go
packageauthuser
import(
"context"
"taskflow/app/models"
"github.com/goravel/framework/contracts/http"
)
typekeystruct{}
funcSet(ctx http.Context, user *models.User){
ctx.WithValue(key{}, user)
}
funcFrom(ctx context.Context)*models.User{
user, _ := ctx.Value(key{}).(*models.User)
return user
}
The key is key{}, a type only this package can make. A string like "user" could clash with other values on the request, the framework’s included. Go’s context docs advise the same.
Set takes Goravel’s http.Context. Its WithValue changes the request in place. Go’s own context.WithValue returns a new context, which the handler would never see.
From takes any context.Context, so code that only has a context.Context, such as a gate, can read the user too.
From returns nil when no user was set. Only call it in a handler behind Auth.
In the middleware, load the user after Parse and store it:
A JWT can be checked without the database, and that is much of its appeal. TaskFlow already asks Redis on each request whether the token was logged out, and now it reads the user from Postgres too, even for /tasks, which doesn’t use it. That is one lookup by primary key, and a deleted user is shut out at once.
Limiting login attempts
Nothing stops a script from trying passwords at login all day. A rate limiter gives a rule a name, and the built-in Throttle middleware applies it to a route. Register it in the WithCallback from Models & Migrations:
"error":"Too many login attempts. Try again in a minute.",
}).Abort()
})
})
}).
Create()
}
PerMinute(5) allows five tries a minute.
By sets what is counted, here the email. A script guessing one account gets five tries a minute. In return, anyone who knows an email can keep its owner locked out by sending five requests a minute.
Response sets the answer once the limit is hit. Without it, the client gets a 429 with an empty body.
Put Throttle("login") on the login route. Goravel’s middleware package has the same name as TaskFlow’s, so it is imported as httpmiddleware:
for i in123456;docurl-s-o/dev/null-w"%{http_code} "-XPOSThttp://localhost:3000/api/v1/login-H"Content-Type: application/json"-d'{"email":"ana@example.com","password":"wrong"}';done;echo
401401401401401429
The sixth gets 429. Logins that work count too, because Throttle counts every request. Counting only failures would mean moving the limit into the Login handler.
The email as it was typed
The limiter counts the email exactly as it was sent, so check whether another spelling gets past it. Within the minute, log in as Ana with the right password, then with capitals, then with a space in front:
Ana’s email is blocked, but both other spellings work. The limiter reads the email before the form request trims and lowercases it, so each spelling gets a counter of its own. Redis shows three:
"error":"Too many login attempts. Try again in a minute.",
}).Abort()
})
})
}).
Create()
}
Wait a minute for the old counters to run out. Now five wrong tries in different spellings share one counter, and the right password is refused too until the minute is up:
Terminal window
for email in"ana@example.com""Ana@Example.com"" ana@example.com""ANA@EXAMPLE.COM""ana@example.com ";docurl-s-o/dev/null-w"%{http_code} "-XPOSThttp://localhost:3000/api/v1/login-H"Content-Type: application/json"-d"{\"email\":\"$email\",\"password\":\"wrong\"}";done;echo
It doesn’t stop one client from trying a common password against many emails, and register still tells anyone whether an email has an account. Both need a limit per client, and Goravel counts a client by IP. Throttle does that when a limiter has no key, counting by ctx.Request().Ip() and the path.
Counting the email and the IP together, as Laravel’s starter kits do, is weaker than the email alone. With the Gin driver, Ip() trusts the X-Forwarded-For header from any client, so a client can send a new IP on every request, and one account gets unlimited tries. Change the key to the email and the IP:
"error":"Too many login attempts. Try again in a minute.",
}).Abort()
})
})
}).
Create()
}
Lock Ana’s email, then send a new header on each try:
Terminal window
for i in123456;docurl-s-o/dev/null-w"%{http_code} "-XPOSThttp://localhost:3000/api/v1/login-H"Content-Type: application/json"-d'{"email":"ana@example.com","password":"wrong"}';done;echo
401401401401401429
for i in123456;docurl-s-o/dev/null-w"%{http_code} "-XPOSThttp://localhost:3000/api/v1/login-H"Content-Type: application/json"-H"X-Forwarded-For: 10.0.0.$i"-d'{"email":"ana@example.com","password":"wrong"}';done;echo
401401401401401401
Every try gets a counter of its own, so the email never locks. Change the key back to By(email). TaskFlow counts by email only. A per-IP limit falls to the same header. A limit per client needs an IP the client can’t fake, which means telling the server which proxies to trust.
The task and user routes now turn away anyone without a valid token, handlers get the signed-in user from the middleware, and login allows five tries a minute per email. A member can still do anything a manager can.