Authentication
Introduction
Goyave provides a convenient and expandable basis for handling authentication in your application with the goyave.dev/goyave/v5/auth
package.
Authentication can be enabled when registering your routes by adding the authentication middleware and adding the auth.MetaAuth
meta to the router or routes that require authentication.
The authentication middleware uses an authenticator. An authenticator is a component that implements the method Authenticate(request *goyave.Request) (*T, error)
. This method is responsible of retrieving the credentials in the given request (usually in the Authorization
header), check them and return a DTO of the authenticated user, If the authentication failed, it returs a localized error message explaining why authentication failed.
Authenticators therefore depend on a service that allow them to retrieve the user information from the database. This service must implement auth.UserService[T]
, which defines a method FindByUsername(ctx context.Context, username any) (*T, error)
. Note that the "username" can be anything provided it can identify a user: an ID, an email, a unique username.
On successful authentication, the auth middleware automatically sets the request.User
field to the value returned by the authenticator. Otherwise 401 Unauthorized
is returned with the localized message mentioned previously:{"error": "authentication error reason"}
The following example applies basic authentication of users using the Password
field in the dto.InternalUser
:
import (
"goyave.dev/goyave/v5/auth"
"my-project/dto"
"my-project/service"
//...
)
userService := server.Service(service.User).(auth.UserService[dto.InternalUser])
authMiddleware := auth.Middleware(auth.NewBasicAuthenticator(userService, "Password"))
router.GlobalMiddleware(authMiddleware).SetMeta(auth.MetaAuth, true)
INFO
- If the
auth.MetaAuth
is missing or not equal totrue
, the authentication middleware is skipped. In route groups that have authentication enabled, you can disable authentication on specific routes or subrouters by setting theauth.MetaAuth
tofalse
. You can use the authentication middleware as a global middleware. - Note that we used
dto.InternalUser
here as user type. It is recommended to use a DTO different from the user DTO sent to clients in responses to have better control over the exposed information. - The "not found" and "method not allowed" routes are never authenticated, even if the middleware is global. This is because these routes don't have a parent router, meaning meta cannot be applied to them.
When a user is successfully authenticated on a protected route, its information is available in the following handlers in the stack through the request's User
field.
func (ctrl *Controller) ShowProfile(response *goyave.Response, request *goyave.Request) {
user := request.User.(*dto.InternalUser)
response.JSON(http.StatusOK, typeutil.MustConvert[*dto.User](user))
}
User service
Here is an example of service implementation of auth.UserService[dto.InternaleUser]
:
// service/user/user.go
func (s *Service) FindByUsername(ctx context.Context, username any) (*dto.InternalUser, error) {
u, err := s.repository.FindByUsername(ctx, fmt.Sprintf("%v", username))
return typeutil.MustConvert[*dto.InternalUser](u), errors.New(err)
}
// database/repository/user.go
func (r *User) FindByUsername(ctx context.Context, email string) (*model.User, error) {
var user *model.User
db := session.DB(ctx, r.DB).Where("email", email).First(&user)
return user, errors.New(db.Error)
}
TIP
FindByUsername
receives any
as username. Make sure to check it or convert it a needed from inside your service so it can be used safely in the repository.
Basic auth
Basic authentication is an authentication method using the Authorization
header and a simple username and password combination with the following format: username:password
, encoded in base64. There are two built-in Authenticators for Basic auth: one using the database as a user providers, and one using configuration.
Database provider
This authenticator fetches the user information from the database. The password is then retrieved from the value of a field in the DTO returned by the service. This field is identified by its name, given in the auth.NewBasicAuthenticator(userService, passwordFieldName)
constructor.
The password given in the request is compared with the hashed password stored in the database using bcrypt
.
userService := server.Service(service.User).(auth.UserService[dto.InternalUser])
authMiddleware := auth.Middleware(auth.NewBasicAuthenticator(userService, "Password"))
// dto/user.go
type InternalUser struct {
User
Password string `json:"password"` // This field's value will be used to check the password
}
This provider supports the Optional
flag, which defines if the authenticator allows requests that don't provide credentials. Handlers should therefore check if request.User
is not nil
before accessing it.
authenticator := auth.NewBasicAuthenticator(userService, "Password")
authenticator.Optional = true
Config provider
This authenticator fetches the user information from the config. This method is good for quick proof-of-concepts, as it requires minimum setup, but shouldn't be used in real-world applications.
- The
auth.basic.username
config entry defines the username that must be matched. - The
auth.basic.password
config entry defines the password that must be matched.
To apply this protection to your routes, start by adding the auth
category at the root of your configuration, and the auth.basic
sub-category:
{
//...
"auth": {
"basic": {
"username": "admin",
"password": "admin"
}
}
}
Then, add the config basic auth middlewere:
router.GlobalMiddleware(auth.ConfigBasicAuth()).SetMeta(auth.MetaAuth, true)
// or
authMiddleware := auth.Middleware(&auth.ConfigBasicAuthenticator{})
router.GlobalMiddleware(authMiddleware).SetMeta(auth.MetaAuth, true)
The DTO used for this authenticator is *auth.BasicUser
:
type BasicUser struct {
Name string
}
You can test the authentication by requesting a route protected by basic authentication like so:
$ curl -u username:password http://localhost:8080/hello
JSON Web Token (JWT)
JWT, or JSON Web Token, is an open standard of authentication that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA. Out of the box, Goyave supports HMAC, RSA (without key password) and ECDSA. RSA and ECDSA require PEM-encoded keys. Goyave uses the golang-jwt/jwt library in the background.
JWT Authentication comes with the auth.jwt.expiry
configuration entry, which defines the number of seconds a token is valid for and defaults to 300
(5 minutes).
JWT Service
auth.JWTService
is a built-in service managing signing keys and allowing JWT generation. It supports token generation using the following signing methods:
- HMAC: using the secret defined in configuration
auth.jwt.secret
. - RSA: using the key pair defined in configuration
auth.jwt.rsa.private
andauth.jwt.rsa.public
. The values represent a path to the file containing the key in the service's file system. The key must be PEM-encoded. - ECDSA: using the key pair defined in configuration
auth.jwt.ecdsa.private
andauth.jwt.ecdsa.public
. The values represent a path to the file containing the key in the service's file system. The key must be PEM-encoded.
INFO
The keys are loaded and parsed once, then cached for better performance.
WARNING
Make sure your HMAC secret is securely generated and is long enough:
- HMAC-SHA256: the secret must be 256+ bits long
- HMAC-SHA384: the secret must be 384+ bits long
- HMAC-SHA512: the secret must be 512+ bits long
If the service has not been registered yet when the built-in JWTAuthenticator
or the JWTController
are initialized. It will be registered automatically using the osfs.FS
file system. If you want to use another file system for your keys storage, you can initialize and register the service manually:
jwtService := auth.NewJWTService(server.Config(), filesystem)
server.RegisterService(jwtService)
Generating tokens
Use the auth.JWTService
's GenerateToken()
or GenerateTokenWithClaims()
to generate a new JWT.
GenerateToken
generate a new JWT with default settings:
- The token is created using the HMAC SHA256 method and signed using the
auth.jwt.secret
config entry. - The token is set to expire in the amount of seconds defined by the
auth.jwt.expiry
config entry. - The generated token will contain the following claims:
sub
: has the value of theid
parameter.nbf
: "Not before", the current timestamp is used.exp
: "Expiry", the current timestamp plus theauth.jwt.expiry
config entry.
- The token is returned as a
string
.
token, err := jwtService.GenerateToken("johndoe@example.org")
GenerateTokenWithClaims
lets you add custom claims and use another signing method:
- The token is set to expire in the amount of seconds defined by the
auth.jwt.expiry
config entry. - Depending on the given signing method, the following configuration entries will be used:
- RSA:
auth.jwt.rsa.private
: path to the private PEM-encoded RSA key. - ECDSA:
auth.jwt.ecdsa.private
: path to the private PEM-encoded ECDSA key. - HMAC:
auth.jwt.secret
: HMAC secret
- RSA:
- The generated token will also contain the following claims:
nbf
: "Not before", the current timestamp is usedexp
: "Expiry", the current timestamp plus theauth.jwt.expiry
config entry.
nbf
andexp
can be overridden if they are set in theclaims
parameter.
claims := jwt.MapClaims{
"sub": "johndoe@example.org",
}
jwtAsString, err := jwtService.GenerateTokenWithClaims(claims, jwt.SigningMethodES256)
JWT Authenticator
authenticator := auth.NewJWTAuthenticator(userService)
authMiddleware := auth.Middleware(authenticator)
router.GlobalMiddleware(authMiddleware).SetMeta(auth.MetaAuth, true)
Routes protected by this authenticator will have to contain the following header:
Authorization: Bearer <YOUR_TOKEN>
- This provider supports the
Optional
flag, which defines if the authenticator allows requests that don't provide credentials. Handlers should therefore check ifrequest.User
is notnil
before accessing it. - You can define a custom ID claim name with the
ClaimName
option. By default, thesub
claim is used to retrieve the username sent to the user service. - You can define the desired signing method with
SigningMethod
.
import (
"github.com/golang-jwt/jwt"
"goyave.dev/goyave/v5/auth"
//...
)
authenticator := auth.NewJWTAuthenticator(userService)
authenticator.Optional = true
authenticator.ClaimName = "userid"
authenticator.SigningMethod = jwt.SigningMethodES256
If a token is valid (even if authentication fails), its claims are put into request.Extra
with the auth.ExtraJWTClaims
key, so you can access them in any subsequent handler:
import (
"github.com/golang-jwt/jwt"
"goyave.dev/goyave/v5/auth"
//...
)
func (ctrl *Controller) Handler(response *goyave.Response, request *goyave.Request) {
claims := request.Extra[auth.ExtraJWTClaims{}].(jwt.MapClaims)
//...
}
RSA
If you expect tokens to be signed with RSA, you will need to add the auth.jwt.rsa.public
configuration entry. This entry defines the path to the PEM-encoded RSA public key file in the JWT service's file system. Then, specify the expected signature method in the SigningMethod
field of auth.JWTAuthenticator
.
TIP
You can find the list of available methods in the jwt-go documentation.
- For testing purposes, you can generate an RSA key-pair using OpenSSL:
openssl genrsa -out rsa-private.pem 2048
openssl rsa -in rsa-private.pem -outform PEM -pubout -out rsa-public.pem
ECDSA
If you expect tokens to be signed with ECDSA, you will need to add the auth.jwt.ecdsa.public
configuration entry. This entry defines the path to the PEM-encoded ECDSA public key file in the JWT service's file system. Then, specify the expected signature method in the SigningMethod
field of auth.JWTAuthenticator
.
TIP
- You can find the list of available methods in the jwt-go documentation.
- For testing purposes, you can generate an ECDSA key-pair using OpenSSL:
openssl ecparam -name prime256v1 -genkey -noout -out ecdsa-private.key
openssl pkcs8 -topk8 -in ecdsa-private.key -out ecdsa-private.pem
openssl ec -in ecdsa-private.pem -pubout -out ecdsa-public.pem
Login controller
auth.JWTController
is a simple controller adding a login route for JWT password grant. Similarly to the basic authenticator, it uses the password retrieved from the value of a field in the DTO returned by the service. The password given in the request is compared with the hashed password stored in the database using bcrypt. This controller implements goyave.Registrer
so its routes will automatically be registered when using it with router.Controller()
.
userService := server.Service(service.User).(auth.UserService[dto.InternalUser])
router.Controller(auth.NewJWTController(userService, "Password"))
// dto/user.go
type InternalUser struct {
User
Password string `json:"password"` // This field's value will be used to check the password
}
The controller has a route POST /login
with validation. By default, the controller will use the "username" and "password" fields from incoming requests for the authentication process. This can be changed by modifying the controller's UsernameField
and PasswordField
fields:
jwtController := auth.NewJWTController(userService, "Password")
jwtController.UsernameField = "email"
jwtController.PasswordField = "pwd"
INFO
Changing the username and password fields will also change the validation automatically.
On successful authentication, a response containing the token will be returned:
{
"token": "eyJhbGc..."
}
If the authentication fails, a response with a localized error message (auth.invalid-credentials
) and status code 401 Unauthorized
is returned.
Signing method
As JWTController
generates token for you, you can also customize the signing method it uses. By default, HMAC-SHA256 is used. You can override this by changing the SigningMethod
field:
import "github.com/golang-jwt/jwt"
//...
jwtController := auth.NewJWTController(userService, "Password")
jwtController.SigningMethod = jwt.SigningMethodES256
Custom token generation
You can also override the token generation logic executed by the controller on successful authentication by setting the TokenFunc
field:
jwtController := auth.NewJWTController(userService, "Password")
jwtController.TokenFunc = func(_ *goyave.Request, user *dto.InternalUser) (string, error) {
jwtService := server.Service(auth.JWTServiceName).(*auth.JWTService)
return jwtService.GenerateTokenWithClaims(jwt.MapClaims{
"sub": user.ID,
"name": user.Name,
}, jwt.SigningMethodHS256)
}
TIP
auth.TokenFunc[T]
is an alias for func(request *goyave.Request, user *T) (string, error)
Custom authenticator
If none of the built-in authentication method suits your needs, you can easily implement a new one and plug it right into the authentication middleware.
The typical auth.Authenticator
takes a generic parameter T
, representing the user DTO returned by the user service.
In the following example, we are going to authenticate the user using a simple token stored in the database:
// http/auth/custom.go
package auth
import (
"context"
"fmt"
stderrors "errors"
"gorm.io/gorm"
"goyave.dev/goyave/v5"
"goyave.dev/goyave/v5/util/errors"
)
type UserService[T any] interface {
FindUserByToken(ctx context.Context, token string) (*T, error)
}
type CustomAuthenticator[T any] struct {
goyave.Component
UserService UserService[T]
}
func (a *CustomAuthenticator[T]) Authenticate(request *goyave.Request) (*T, error) {
token, ok := request.BearerToken()
if !ok {
return nil, fmt.Errorf(request.Lang.Get("auth.no-credentials-provided"))
}
user, err := a.UserService.FindUserByToken(request.Context(), token)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf(request.Lang.Get("auth.invalid-credentials"))
}
panic(errors.New(err))
}
return user, nil
}
INFO
It is not necessary to wrap errors returned by an authenticator, as they are used only for writing the response to the client. If an error (for example a DB error) happens, you can panic
with a wrapped error.
The auth.Unauthorizer
interface lets authenticators define a custom behavior when the authentication fails. This is useful if you want to customize the response sent to the client.
func (a *CustomAuthenticator[T]) OnUnauthorized(response *goyave.Response, request *goyave.Request, err error) {
response.JSON(http.StatusUnauthorized, map[string]string{"authError": err.Error()})
}