This is part of a series, not suited for beginners, but welcome too! You can
check it out from the start and review how to get up to this part, as always all the code is at github for
part 4
Today we'll add Goth and user Auth0 as our authentication provider to connect
users via google and/or facebook - or any Identity provider using OAuth2
Adding Goth into the project
Let's add Goth to our project:
$ go get -u github.com/markbates/goth
We can start referencing goth into our server code, there are a few steps to
integrate Goth completely into the flow of the app.
First, I want to keep the configuration as clean as possible and do it from the
go-gql-server definition in cmd/go-gql-server
for this I create a configuration type
in pkg/utils/types.go
:
// AuthProvider defines the configuration for the Goth config
type AuthProvider struct {
Provider string
ClientKey string
Secret string
Domain string // If needed, like with auth0
Scopes []string
}
This way we can use configuration properties needed to instantiate a Goth in
the server, also, we can create a way to type in all configurations in a ordered
fashion in utils/types.go
:
package utils
// ContextKey defines a type for context keys shared in the app
type ContextKey string
// ServerConfig defines the configuration for the server
type ServerConfig struct {
Host string
Port string
URISchema string
Version string
SessionSecret string
JWT JWTConfig
GraphQL GQLConfig
Database DBConfig
AuthProviders []AuthProvider
}
//JWTConfig defines the options for JWT tokens
type JWTConfig struct {
Secret string
Algorithm string
}
// GQLConfig defines the configuration for the GQL Server
type GQLConfig struct {
Path string
PlaygroundPath string
IsPlaygroundEnabled bool
}
// DBConfig defines the configuration for the DB config
type DBConfig struct {
Dialect string
DSN string
SeedDB bool
LogMode bool
AutoMigrate bool
}
// AuthProvider defines the configuration for the Goth config
type AuthProvider struct {
Provider string
ClientKey string
Secret string
Domain string // If needed, like with auth0
Scopes []string
}
// ListenEndpoint builds the endpoint string (host + port)
func (s *ServerConfig) ListenEndpoint() string {
if s.Port == "80" {
return s.Host
}
return s.Host + ":" + s.Port
}
// VersionedEndpoint builds the endpoint string (host + port + version)
func (s *ServerConfig) VersionedEndpoint(path string) string {
return "/" + s.Version + path
}
// SchemaVersionedEndpoint builds the schema endpoint string (schema + host + port + version)
func (s *ServerConfig) SchemaVersionedEndpoint(path string) string {
if s.Port == "80" {
return s.URISchema + s.Host + "/" + s.Version + path
}
return s.URISchema + s.Host + ":" + s.Port + "/" + s.Version + path
}
You can notice that now we can even versionate the endpoints here, all from the
configuration file .env
(there's the .env/example
file in the repo too)
So, then we can add into the serverconf
variable a list of providers, and all
the different configurations and pass the server configuration down to the
pkg/server/main.go
from cmd/go-gql-server/main.go
:
var serverconf = &utils.ServerConfig{
Host: utils.MustGet("SERVER_HOST"),
Port: utils.MustGet("SERVER_PORT"),
URISchema: utils.MustGet("SERVER_URI_SCHEMA"),
Version: utils.MustGet("SERVER_PATH_VERSION"),
SessionSecret: utils.MustGet("SESSION_SECRET"),
JWT: utils.JWTConfig{
Secret: utils.MustGet("AUTH_JWT_SECRET"),
Algorithm: utils.MustGet("AUTH_JWT_SIGNING_ALGORITHM"),
},
GraphQL: utils.GQLConfig{
Path: utils.MustGet("GQL_SERVER_GRAPHQL_PATH"),
PlaygroundPath: utils.MustGet("GQL_SERVER_GRAPHQL_PLAYGROUND_PATH"),
IsPlaygroundEnabled: utils.MustGetBool("GQL_SERVER_GRAPHQL_PLAYGROUND_ENABLED"),
},
Database: utils.DBConfig{
Dialect: utils.MustGet("GORM_DIALECT"),
DSN: utils.MustGet("GORM_CONNECTION_DSN"),
SeedDB: utils.MustGetBool("GORM_SEED_DB"),
LogMode: utils.MustGetBool("GORM_LOGMODE"),
AutoMigrate: utils.MustGetBool("GORM_AUTOMIGRATE"),
},
AuthProviders: []utils.AuthProvider{
utils.AuthProvider{
Provider: "google",
ClientKey: utils.MustGet("PROVIDER_GOOGLE_KEY"),
Secret: utils.MustGet("PROVIDER_GOOGLE_SECRET"),
},
utils.AuthProvider{
Provider: "auth0",
ClientKey: utils.MustGet("PROVIDER_AUTH0_KEY"),
Secret: utils.MustGet("PROVIDER_AUTH0_SECRET"),
Domain: utils.MustGet("PROVIDER_AUTH0_DOMAIN"),
Scopes: strings.Split(utils.MustGet("PROVIDER_AUTH0_SCOPES"), ","),
},
},
}
orm, err := orm.Factory(serverconf)
defer orm.DB.Close()
if err != nil {
logger.Panic(err)
}
server.Run(serverconf, orm)
Now, getting back into the flow, we have to create handlers for our server that
will manage the authenticated paths, this is done creating the proper middleware
for the auth handling, let's create a route in our server package called auth
pkg/server/routes/auth.go
:
package routes
import (
"github.com/cmelgarejo/go-gql-server/internal/handlers/auth"
"github.com/cmelgarejo/go-gql-server/internal/orm"
"github.com/cmelgarejo/go-gql-server/pkg/utils"
"github.com/gin-gonic/gin"
)
// Auth routes
func Auth(cfg *utils.ServerConfig, r *gin.Engine, orm *orm.ORM) error {
// OAuth handlers
g := r.Group(cfg.VersionedEndpoint("/auth"))
g.GET("/:provider", auth.Begin())
g.GET("/:provider/callback", auth.Callback(cfg, orm))
// g.GET(:provider/refresh", auth.Refresh(cfg, orm))
return nil
}
As you might have noticed, now the routes receive the ServerConfig
struct and
that way we can use the VersionedEndpoint
method to build the versioned path
for our server, and the /:provider
path refers to the provider you want to
authenticate with, as configured now, I just added google and auth0, but since
Goth takes many providers, you can add you own as you like with in the config
array struct of providers
Now, to create the internal/handlers/auth
middleware and handler for this
to work: internal/handlers/auth/main.go
:
//
package auth
import (
"context"
"net/http"
"github.com/cmelgarejo/go-gql-server/pkg/utils"
"github.com/gin-gonic/gin"
)
func addProviderToContext(c *gin.Context, value interface{}) *http.Request {
return c.Request.WithContext(context.WithValue(c.Request.Context(),
string(utils.ProjectContextKeys.ProviderCtxKey), value))
}
Here we see that we add the provider handler to the current context of the
request, in order for this handle the auth downstream of it, how? well, binding
the auth middleware internal/handlers/auth/middleware/main.go
:
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"github.com/cmelgarejo/go-gql-server/pkg/utils"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)
var (
// TokenHeadName is a string in the header. Default value is "Bearer"
TokenHeadName = "Bearer"
// APIKeyLookup is a string in the form of "<source>:<name>" that is used
// to extract token from the request.
// Optional. Default value "header:Authorization".
// Possible values:
// - "header:<name>"
// - "query:<name>"
// - "cookie:<name>"
APIKeyLookup = "query:api_key,cookie:api_key,header:X-API-KEY"
// TokenLookup is a string in the form of "<source>:<name>" that is used
// to extract token from the request.
// Optional. Default value "header:Authorization".
// Possible values:
// - "header:<name>"
// - "query:<name>"
// - "cookie:<name>"
TokenLookup = "query:token,cookie:jwt,header:Authorization"
// ErrNoClaims when HTTP status 403 is given
ErrNoClaims = errors.New("invalid token")
// ErrForbidden when HTTP status 403 is given
ErrForbidden = errors.New("you don't have permission to access this resource")
// ErrExpiredToken indicates JWT token has expired. Can't refresh.
ErrExpiredToken = errors.New("token is expired")
// ErrEmptyAuthHeader can be thrown if authing with a HTTP header, the Auth header needs to be set
ErrEmptyAuthHeader = errors.New("auth header is empty")
// ErrEmptyAPIKeyHeader can be thrown if authing with a HTTP header, the Auth header needs to be set
ErrEmptyAPIKeyHeader = errors.New("api key header is empty")
// ErrMissingExpField missing exp field in token
ErrMissingExpField = errors.New("missing exp field")
// ErrInvalidAuthHeader indicates auth header is invalid, could for example have the wrong Realm name
ErrInvalidAuthHeader = errors.New("auth header is invalid")
// ErrEmptyQueryToken can be thrown if authing with URL Query, the query token variable is empty
ErrEmptyQueryToken = errors.New("query token is empty")
// ErrEmptyCookieToken can be thrown if authing with a cookie, the token cokie is empty
ErrEmptyCookieToken = errors.New("cookie token is empty")
// ErrEmptyParamToken can be thrown if authing with parameter in path, the parameter in path is empty
ErrEmptyParamToken = errors.New("parameter token is empty")
// ErrInvalidSigningAlgorithm indicates signing algorithm is invalid, needs to be HS256, HS384, HS512, RS256, RS384 or RS512
ErrInvalidSigningAlgorithm = errors.New("invalid signing algorithm")
)
func jwtFromHeader(c *gin.Context, key string) (string, error) {
authHeader := c.Request.Header.Get(key)
if authHeader == "" {
return "", ErrEmptyAuthHeader
}
parts := strings.SplitN(authHeader, " ", 2)
if !(len(parts) == 2 && parts[0] == TokenHeadName) {
return "", ErrInvalidAuthHeader
}
return parts[1], nil
}
func apiKeyFromHeader(c *gin.Context, key string) (string, error) {
apiKey := c.Request.Header.Get(key)
if apiKey == "" {
return "", ErrEmptyAPIKeyHeader
}
return apiKey, nil
}
func tokenFromQuery(c *gin.Context, key string) (string, error) {
token := c.Query(key)
if token == "" {
return "", ErrEmptyQueryToken
}
return token, nil
}
func tokenFromCookie(c *gin.Context, key string) (string, error) {
cookie, _ := c.Cookie(key)
if cookie == "" {
return "", ErrEmptyCookieToken
}
return cookie, nil
}
func tokenFromParam(c *gin.Context, key string) (string, error) {
token := c.Param(key)
if token == "" {
return "", ErrEmptyParamToken
}
return token, nil
}
// ParseToken parse jwt token from gin context
func ParseToken(c *gin.Context, cfg *utils.ServerConfig) (t *jwt.Token, err error) {
var token string
methods := strings.Split(TokenLookup, ",")
for _, method := range methods {
if len(token) > 0 {
break
}
parts := strings.Split(strings.TrimSpace(method), ":")
k := strings.TrimSpace(parts[0])
v := strings.TrimSpace(parts[1])
switch k {
case "header":
token, err = jwtFromHeader(c, v)
case "query":
token, err = tokenFromQuery(c, v)
case "cookie":
token, err = tokenFromCookie(c, v)
case "param":
token, err = tokenFromParam(c, v)
}
}
if err != nil {
return nil, err
}
SigningAlgorithm := cfg.JWT.Algorithm
Key := []byte(cfg.JWT.Secret)
return jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
if jwt.GetSigningMethod(SigningAlgorithm) != t.Method {
return nil, ErrInvalidSigningAlgorithm
}
// save token string if vaild
// c.Set("AUTH_JWT_TOKEN", token)
return Key, nil
})
}
// ParseAPIKey parse api key from gin context
func ParseAPIKey(c *gin.Context, cfg *utils.ServerConfig) (apiKey string, err error) {
methods := strings.Split(APIKeyLookup, ",")
for _, method := range methods {
if len(apiKey) > 0 {
break
}
parts := strings.Split(strings.TrimSpace(method), ":")
k := strings.TrimSpace(parts[0])
v := strings.TrimSpace(parts[1])
switch k {
case "header":
apiKey, err = apiKeyFromHeader(c, v)
case "query":
apiKey, err = tokenFromQuery(c, v)
case "cookie":
apiKey, err = tokenFromCookie(c, v)
case "param":
apiKey, err = tokenFromParam(c, v)
}
}
if err != nil {
return "", err
}
return apiKey, nil
}
func addToContext(c *gin.Context, key utils.ContextKey, value interface{}) *http.Request {
return c.Request.WithContext(context.WithValue(c.Request.Context(), key, value))
}
You might noticed again that here we also want to use API keys to auth requests,
these will be handled on our side, generating secure enough API keys for machine
to machine communications and integrations to our graphql API.
Again, all that this middleware does is trying to get the JWT tokens generated
by our providers and try to autheticate against our users
records, that might
come from a cookie, a header or event a querystring param, as needed or
implemented by our client(s)
And the actual auth middleware handling method that will enforce all this is in
internal/handlers/auth/middleware/auth.go
:
package middleware
import (
"net/http"
"github.com/cmelgarejo/go-gql-server/internal/logger"
"github.com/cmelgarejo/go-gql-server/internal/orm"
"github.com/cmelgarejo/go-gql-server/pkg/utils"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)
func authError(c *gin.Context, err error) {
errKey := "message"
errMsgHeader := "[Auth] error: "
e := gin.H{errKey: errMsgHeader + err.Error()}
c.AbortWithStatusJSON(http.StatusUnauthorized, e)
}
// Middleware wraps the request with auth middleware
func Middleware(path string, cfg *utils.ServerConfig, orm *orm.ORM) gin.HandlerFunc {
logger.Info("[Auth.Middleware] Applied to path: ", path)
return gin.HandlerFunc(func(c *gin.Context) {
if a, err := ParseAPIKey(c, cfg); err == nil {
user, err := orm.FindUserByAPIKey(a)
if err != nil {
authError(c, ErrForbidden)
}
logger.Info("User: ", user)
c.Next()
} else {
if err != ErrEmptyAPIKeyHeader {
authError(c, err)
} else {
t, err := ParseToken(c, cfg)
if err != nil {
authError(c, err)
} else {
if claims, ok := t.Claims.(jwt.MapClaims); ok {
if claims["exp"] != nil {
issuer := claims["iss"].(string)
userid := claims["jti"].(string)
email := claims["email"].(string)
if user, err := orm.FindUserByJWT(email, issuer, userid); err != nil {
authError(c, ErrForbidden)
} else {
c.Request = addToContext(c, utils.ProjectContextKeys.UserCtxKey, user)
c.Next()
}
} else {
authError(c, ErrMissingExpField)
}
} else {
authError(c, err)
}
}
}
}
})
}
Here the real search for the user is made in FindUserByJWT
for JWT tokens and
FindUserByAPIKey
for the API keys if sent, those functions live in
internal/orm/main.go
.
//FindUserByAPIKey finds the user that is related to the API key
func (o *ORM) FindUserByAPIKey(apiKey string) (*models.User, error) {
db := o.DB.New()
uak := &models.UserAPIKey{}
if apiKey == "" {
return nil, errors.New("API key is empty")
}
if err := db.Preload("User").Where("api_key = ?", apiKey).Find(uak).Error; err != nil {
return nil, err
}
return &uak.User, nil
}
// FindUserByJWT finds the user that is related to the APIKey token
func (o *ORM) FindUserByJWT(email string, provider string, userID string) (*models.User, error) {
db := o.DB.New()
up := &models.UserProfile{}
if provider == "" || userID == "" {
return nil, errors.New("provider or userId empty")
}
if err := db.Preload("User").Where("email = ? AND provider = ? AND external_user_id = ?", email, provider, userID).First(up).Error; err != nil {
return nil, err
}
return &up.User, nil
}
// UpsertUserProfile saves the user if doesn't exists and adds the OAuth profile
func (o *ORM) UpsertUserProfile(input *goth.User) (*models.User, error) {
db := o.DB.New()
u := &models.User{}
up := &models.UserProfile{}
u, err := transformations.GothUserToDBUser(input, false)
if err != nil {
return nil, err
}
if tx := db.Where("email = ?", input.Email).First(u); !tx.RecordNotFound() && tx.Error != nil {
return nil, tx.Error
}
if tx := db.Model(u).Save(u); tx.Error != nil {
return nil, err
}
if tx := db.Where("email = ? AND provider = ? AND external_user_id = ?",
input.Email, input.Provider, input.UserID).First(up); !tx.RecordNotFound() && tx.Error != nil {
return nil, err
}
up, err = transformations.GothUserToDBUserProfile(input, false)
if err != nil {
return nil, err
}
up.User = *u
if tx := db.Model(up).Save(up); tx.Error != nil {
return nil, tx.Error
}
logger.Info(u.ID)
return u, nil
}
There's also the UpsertUserProfile
method that saves the profile into our
database so it can be retrieved later as we want at user_profiles
Continuing the auth flow, here's the handlers for the routing of the login/out
with oAuth providers internal/handlers/auth/handlers.go
:
package auth
import (
"net/http"
"time"
"github.com/cmelgarejo/go-gql-server/internal/orm"
"github.com/dgrijalva/jwt-go"
"github.com/cmelgarejo/go-gql-server/internal/logger"
"github.com/cmelgarejo/go-gql-server/pkg/utils"
"github.com/gin-gonic/gin"
"github.com/markbates/goth/gothic"
)
// Claims JWT claims
type Claims struct {
Email string `json:"email"`
jwt.StandardClaims
}
// Begin login with the auth provider
func Begin() gin.HandlerFunc {
return func(c *gin.Context) {
// You have to add value context with provider name to get provider name in GetProviderName method
c.Request = addProviderToContext(c, c.Param("provider"))
// try to get the user without re-authenticating
if gothUser, err := gothic.CompleteUserAuth(c.Writer, c.Request); err != nil {
gothic.BeginAuthHandler(c.Writer, c.Request)
} else {
logger.Infof("user: %#v", gothUser)
}
}
}
// Callback callback to complete auth provider flow
func Callback(cfg *utils.ServerConfig, orm *orm.ORM) gin.HandlerFunc {
return func(c *gin.Context) {
// You have to add value context with provider name to get provider name in GetProviderName method
c.Request = addProviderToContext(c, c.Param("provider"))
user, err := gothic.CompleteUserAuth(c.Writer, c.Request)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
u, err := orm.FindUserByJWT(user.Email, user.Provider, user.UserID)
// logger.Infof("gothUser: %#v", user)
if err != nil {
if u, err = orm.UpsertUserProfile(&user); err != nil {
logger.Errorf("[Auth.CallBack.UserLoggedIn.UpsertUserProfile.Error]: %q", err)
c.AbortWithError(http.StatusInternalServerError, err)
}
}
// logger.Info("[Auth.CallBack.UserLoggedIn.USER]: ", u)
logger.Info("[Auth.CallBack.UserLoggedIn]: ", u.ID)
jwtToken := jwt.NewWithClaims(jwt.GetSigningMethod(cfg.JWT.Algorithm), Claims{
Email: user.Email,
StandardClaims: jwt.StandardClaims{
Id: user.UserID,
Issuer: user.Provider,
IssuedAt: time.Now().UTC().Unix(),
NotBefore: time.Now().UTC().Unix(),
ExpiresAt: user.ExpiresAt.UTC().Unix(),
},
})
token, err := jwtToken.SignedString([]byte(cfg.JWT.Secret))
if err != nil {
logger.Error("[Auth.Callback.JWT] error: ", err)
c.AbortWithError(http.StatusInternalServerError, err)
return
}
logger.Info("token: ", token)
json := gin.H{
"type": "Bearer",
"token": token,
"refresh_token": user.RefreshToken,
}
c.JSON(http.StatusOK, json)
}
}
// Logout logs out of the auth provider
func Logout() gin.HandlerFunc {
return func(c *gin.Context) {
c.Request = addProviderToContext(c, c.Param("provider"))
gothic.Logout(c.Writer, c.Request)
c.Writer.Header().Set("Location", "/")
c.Writer.WriteHeader(http.StatusTemporaryRedirect)
}
}
This will allow us to go to http://localhost:7777/v1/auth/:provider
such as we
have configured google
and auth0
and we'll have an user created in our API!
Now, you have to configure an app properly at GCP
and then, fill in the data needed for the provider as indicated in
cmd/go-gql-server/main.go
, this will allow you to use the provider and
navigate to http://localhost:7777/v1/auth/google
and to test it out:
Sorry for the spanish but it's the usual OAuth2 consent screen for apps from
google :D
Should you have it all correct, this will happen:
And then we have a user Signup/in already doing all the work :D
Now, use the token in the playground http://localhost:7777/v1/graphql/playground
And run a query or mutation on our server
So with this, we've integrated all!
- gqlgen
- goth
- gorm
How about this: Next part it's setting up a RBAC layer, so we can determine
which roles/users can access information in our API :)
Be on the lookup for that! Again, like the former parts, all the code is
available in the repository here!
If you have questions, critiques and comments go ahead and let's learn more
together!
Top comments (8)
@cmelgarejo thang you very much! Great tutorial exactly what I've been looking for!
Could you maybe explain and add testing in a future part? would be very helpful!
Adding local auth would also be nice:)
Thanks Tobias! I shall add part 5 with tests in a couple of days ;)
Sounds great looking forward to it
I've been looking for these kind of tutorials to be able to fully switch from nestjs to golang. Really useful, thanks! Do you actually use this setup in production ? How's the performance so far ? And is there any drawbacks as well using gqlgen for graphql ?
@cmelgarejo thanks for a great tutorials! Looking forward for the next series :)
Btw, is it possible to do the basic authentication using goth, using my local db tables?
Well yes, actually that's why I added a fallback to do an API key generation, but you want simeple auth, have to implement your own user/passwd middleware and route in
pkg/server/routes/auth.go
to do a custom a login flow without gothAlright, thanks
it is been a while, since the work was posted, excellent work, I learning a lot... I really appreciate if you show me the roles and tests.... if it is possible it would be really cool.