26 Iam Project How to Design and Implement Access Authentication Functionality

26 How is the IAM project designed and implemented for access authentication? #

Hello, I am Kong Lingfei.

In the previous lesson, we learned about four common methods of application authentication: Basic, Digest, OAuth, and Bearer. In this lesson, let’s take a look at how the IAM project is designed and implemented for authentication.

The IAM project uses Basic authentication and Bearer authentication. Specifically, Basic authentication is used for frontend login scenarios, while Bearer authentication is used for calling backend API services.

Next, let’s first look at the overall design concept of the authentication function in the IAM project.

26 IAM Project How to Design and Implement Access Authentication Functionality #

Before developing the authentication functionality, we need to carefully consider how to design the authentication functionality based on the requirements and conduct a technical review during the design phase. So let’s first look at how to design the authentication functionality for IAM projects.

Firstly, we need to clarify the use cases and requirements for the authentication functionality.

  • The iam-apiserver service of the IAM project provides the management flow function interface of the IAM system. Its clients can be front-end (also known as a console) or app-side.
  • In order to facilitate calling on Linux systems, the IAM project also provides the iamctl command-line tool.
  • To support calling the API interfaces provided by iam-apiserver in third-party code, API calls are also supported.
  • In order to improve the efficiency of calling API interfaces in code, the IAM project provides a Go SDK.

From the above, we can see that iam-apiserver has many clients, and the authentication methods used by each client are different.

The console and app-side require logging in to the system, so they need to use the username: password authentication method, also known as Basic authentication. iamctl, API calls, and Go SDK do not require logging in to the system, so a more secure authentication method, Bearer authentication, can be used. However, Basic authentication, as an integrated authentication method of iam-apiserver, can still be used by iamctl, API calls, and Go SDK.

One thing to note here: if iam-apiserver uses Bearer Token authentication, the most popular token format currently is JWT Token. And JWT Token requires a secret key (referred to as secretKey here) which needs to be maintained for each user in the iam-apiserver service, which increases development and maintenance costs.

There is a better implementation approach in the industry: register the API interfaces provided by iam-apiserver to an API gateway to implement authentication for the iam-apiserver API interfaces through the token authentication function of the API gateway. There are many choices for API gateways, such as Tencent Cloud API Gateway, Tyk, Kong, etc.

Here, please note that the key pairs created by iam-apiserver are for use by iam-authz-server.

In addition, we need to call the RESTful API interface provided by iam-authz-server: /v1/authz, to perform resource authorization. The Bearer authentication method is suitable for API calls.

Of course, /v1/authz can also be directly registered to the API gateway. In actual Go project development, this is also the approach I recommend. However, in this case, in order to demonstrate the process of implementing Bearer authentication, iam-authz-server implements Bearer authentication itself. I will explain this in detail when I talk about the implementation of Bearer authentication by iam-authz-server.

Basic authentication requires a username and password, while Bearer authentication requires a secret key. Therefore, iam-apiserver needs to save username/password, secret keys, and other information in the backend MySQL for persistent storage.

When performing authentication, it is necessary to retrieve and decrypt the password or secret key. There are two ways to query the password or secret key. One way is to query the database when the request arrives. Because the database query operation has high latency, it will lead to a high delay of the API interface, so it is not suitable for use in data flow components. Another way is to cache the password or secret key in memory so that when a request arrives, it can be directly queried from memory, thus improving query speed and API performance.

However, when caching the password or secret key in memory, the consistency between memory and the database needs to be considered, which increases the complexity of code implementation. Because control flow components are not so sensitive to performance latency requirements, while data flow components must achieve very high API performance, iam-apiserver queries the database when the request arrives, while iam-authz-server caches the key information in memory.

Here, we can summarize the authentication design diagram of an IAM project:

In addition, in order to distinguish between control flow and data flow, the CRUD operations for keys are also placed in iam-apiserver, but iam-authz-server needs to use this key information. To solve this problem, the current approach is:

  • iam-authz-server requests all key information from iam-apiserver through the gRPC API.
  • When iam-apiserver has key updates, it will publish a message to the Redis Channel. Since iam-authz-server subscribes to the same Redis Channel, iam-authz-server listens to new messages on the channel, retrieves and parses the messages, and updates the cached key information. This way, we can ensure that the key information cached in iam-authz-server memory remains consistent with the key information in iam-apiserver.

At this point, you might ask: if all keys are cached in iam-authz-server, wouldn’t it take up a lot of memory? Don’t worry, I have considered this problem and calculated it for you: 8GB of memory can approximately store about 80 million key information, which is completely enough. If it is insufficient in the later stage, you can increase the memory.

However, there is still a small flaw here: if Redis is down or there is network jitter, it may cause the key data stored in iam-apiserver and iam-authz-server memory to be inconsistent. But this does not prevent us from learning about the design and implementation of authentication functionality. As for how to ensure data consistency in the cache system, I will specifically introduce it in a special edition in the future.

Finally, one important note: both Basic authentication requests and Bearer authentication requests can be intercepted and replayed. Therefore, to ensure the security of Basic authentication and Bearer authentication, it is necessary to use the HTTPS protocol when communicating with the server.

How does the IAM project implement Basic authentication? #

As we know, the IAM project mainly uses Basic and Bearer authentication methods. We need to support both Basic authentication and Bearer authentication, and choose different authentication methods as needed. This naturally makes us think of using the Strategy design pattern to implement it. Therefore, in the IAM project, I treat each authentication method as a strategy, and use different strategies to employ different authentication methods.

The IAM project implements the following strategies:

  • auto strategy: This strategy selects either Basic authentication or Bearer authentication based on the HTTP headers Authorization: Basic XX.YY.ZZ and Authorization: Bearer XX.YY.ZZ.
  • basic strategy: This strategy implements Basic authentication.
  • jwt strategy: This strategy implements Bearer authentication, where JWT is the specific implementation of Bearer authentication.
  • cache strategy: This strategy is actually an implementation of Bearer authentication, where the token uses the JWT format. It is called Cache authentication because the key ID in the token is retrieved from memory. We will provide a detailed explanation of this later.

The IAM API server achieves API authentication by creating the required authentication strategies and loading them onto the API routes that need authentication. The specific code is as follows:

jwtStrategy, _ := newJWTAuth().(auth.JWTStrategy)
g.POST("/login", jwtStrategy.LoginHandler)
g.POST("/logout", jwtStrategy.LogoutHandler)
// Refresh time can be longer than token timeout
g.POST("/refresh", jwtStrategy.RefreshHandler)

In the above code, we create a variable of type auth.JWTStrategy through the function newJWTAuth, which includes some authentication-related functions.

  • LoginHandler: Implements Basic authentication for login verification.
  • RefreshHandler: Refreshes the expiration time of the token.
  • LogoutHandler: Called when a user logs out. If the authentication-related information is set in the cookie after a successful login, executing the LogoutHandler will clear this information.

Next, let’s discuss LoginHandler, RefreshHandler, and LogoutHandler separately.

  1. LoginHandler

Now, let’s take a look at the LoginHandler Gin middleware function, which is defined in the auth_jwt.go file of the github.com/appleboy/gin-jwt package.

func (mw *GinJWTMiddleware) LoginHandler(c *gin.Context) {
	if mw.Authenticator == nil {
		mw.unauthorized(c, http.StatusInternalServerError, mw.HTTPStatusMessageFunc(ErrMissingAuthenticatorFunc, c))
		return
	}

	data, err := mw.Authenticator(c)

	if err != nil {
		mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c))
		return
	}

	// Create the token
	token := jwt.New(jwt.GetSigningMethod(mw.SigningAlgorithm))
	claims := token.Claims.(jwt.MapClaims)

	if mw.PayloadFunc != nil {
		for key, value := range mw.PayloadFunc(data) {
			claims[key] = value
		}
	}

	expire := mw.TimeFunc().Add(mw.Timeout)
	claims["exp"] = expire.Unix()
	claims["orig_iat"] = mw.TimeFunc().Unix()
	tokenString, err := mw.signedString(token)

	if err != nil {
		mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrFailedTokenCreation, c))
		return
	}

	// set cookie
	if mw.SendCookie {
		expireCookie := mw.TimeFunc().Add(mw.CookieMaxAge)
		maxage := int(expireCookie.Unix() - mw.TimeFunc().Unix())

		if mw.CookieSameSite != 0 {
			c.SetSameSite(mw.CookieSameSite)
		}

		c.SetCookie(
			mw.CookieName,
			tokenString,
			maxage,
			"/",
			mw.CookieDomain,
			mw.SecureCookie,
			mw.CookieHTTPOnly,
		)
	}

	mw.LoginResponse(c, http.StatusOK, tokenString, expire)
}

From the implementation of the LoginHandler function, we can see that it executes the Authenticator function to perform Basic authentication. If the authentication is successful, a JWT token is issued, and the PayloadFunc function is executed to set the token payload. If we set SendCookie=true, it will also add authentication-related information, such as the token, its lifecycle, etc., to the cookie. Finally, it executes the LoginResponse method to return the token and its expiration time.

The Authenticator, PayloadFunc, and LoginResponse functions are specified when creating the JWT authentication strategy. Next, let’s discuss each of them separately.

First, let’s look at the authenticator function. The Authenticator function retrieves the username and password from the HTTP Authorization header and validates whether the password is valid.

func authenticator() func(c *gin.Context) (interface{}, error) {
	return func(c *gin.Context) (interface{}, error) {
		var login loginInfo
		var err error

		// support header and body both
		if c.Request.Header.Get("Authorization") != "" {
			login, err = parseWithHeader(c)
		} else {
			login, err = parseWithBody(c)
		}
		if err != nil {
			return "", jwt.ErrFailedAuthentication
		}

		// Get the user information by the login username.
		user, err := store.Client().Users().Get(c, login.Username, metav1.GetOptions{})
		if err != nil {
			log.Errorf("get user information failed: %s", err.Error())

			return "", jwt.ErrFailedAuthentication
		}

		// Compare the login password with the user password.

The Authenticator function retrieves the username and password either from the header or the request body, and then calls the parseWithHeader or parseWithBody function to parse them. If an error occurs during parsing, it returns jwt.ErrFailedAuthentication. It then obtains the user information based on the login username and compares the login password with the user password.

if err := user.Compare(login.Password); err != nil {
    return "", jwt.ErrFailedAuthentication
}

return user, nil

The Authenticator function needs to get the username and password. It first checks if there is an Authorization request header. If there is, it calls the parseWithHeader function to get the username and password. Otherwise, it calls parseWithBody to get the username and password from the body. If both methods fail, it returns an authentication failure error.

Therefore, the Basic support of the IAM project includes the following two request methods:

$ curl -XPOST -H"Authorization: Basic YWRtaW46QWRtaW5AMjAyMQ==" http://127.0.0.1:8080/login # The username and password are encoded with base64 and passed through the HTTP Authorization Header. It is recommended to use this method because the password is not in plain text.
$ curl -s -XPOST -H'Content-Type: application/json' -d'{"username":"admin","password":"Admin@2021"}' http://127.0.0.1:8080/login # The username and password are passed in the HTTP Body. Since the password is in plain text, this method is not recommended for actual development.

Now let’s take a look at how the parseWithHeader function gets the username and password. Let’s assume our request is:

$ curl -XPOST -H"Authorization: Basic YWRtaW46QWRtaW5AMjAyMQ==" http://127.0.0.1:8080/login

Where YWRtaW46QWRtaW5AMjAyMQ== is generated by the following command:

$ echo -n 'admin:Admin@2021' | base64
YWRtaW46QWRtaW5AMjAyMQ==

The parseWithHeader function actually performs the reverse steps of the above command:

  1. Get the value of the Authorization header and call the strings.SplitN function to get a slice variable auth with the value ["Basic","YWRtaW46QWRtaW5AMjAyMQ=="].
  2. Decode YWRtaW46QWRtaW5AMjAyMQ== with base64 and get admin:Admin@2021.
  3. Call the strings.SplitN function to get admin:Admin@2021, and then get the username admin and the password Admin@2021.

The parseWithBody function calls the ShouldBindJSON function of Gin to parse the username and password from the body.

After obtaining the username and password, the program queries the encrypted password corresponding to the user from the database. Let’s assume it is xxxx. Finally, the authenticator function calls user.Compare to check if xxxx matches the encrypted string generated by user.Compare. If it matches, the authentication succeeds; otherwise, it returns an authentication failure.

Now let’s take a look at the PayloadFunc function:

func payloadFunc() func(data interface{}) jwt.MapClaims {
    return func(data interface{}) jwt.MapClaims {
        claims := jwt.MapClaims{
            "iss": APIServerIssuer,
            "aud": APIServerAudience,
        }
        if u, ok := data.(*v1.User); ok {
            claims[jwt.IdentityKey] = u.Name
            claims["sub"] = u.Name
        }

        return claims
    }
}

The PayloadFunc function sets the iss, aud, sub, and identity fields of the payload in the JWT Token for later use.

Next, let’s look at the third function we mentioned, LoginResponse:

func loginResponse() func(c *gin.Context, code int, token string, expire time.Time) {
    return func(c *gin.Context, code int, token string, expire time.Time) {
        c.JSON(http.StatusOK, gin.H{
            "token":  token,
            "expire": expire.Format(time.RFC3339),
        })
    }
}

This function is used to return the token and its expiration time to the caller after successful Basic authentication:

$ curl -XPOST -H"Authorization: Basic YWRtaW46QWRtaW5AMjAyMQ==" http://127.0.0.1:8080/login
{"expire":"2021-09-29T01:38:49+08:00","token":"XX.YY.ZZ"}

After successful login, the iam-apiserver will return the token and its expiration time. The frontend can store this information in cookies or localStorage, and use the token for authentication in subsequent requests. Using a token for authentication not only improves authentication security, but also avoids database queries, thereby improving authentication efficiency.

  1. RefreshHandler

The RefreshHandler function first performs Bearer authentication. If the authentication passes, it will reissue the token.

  1. LogoutHandler

Finally, let’s take a look at the LogoutHandler function:

func (mw *GinJWTMiddleware) LogoutHandler(c *gin.Context) {
    // delete auth cookie
    if mw.SendCookie {
        if mw.CookieSameSite != 0 {
            c.SetSameSite(mw.CookieSameSite)
        }

        c.SetCookie(
            mw.CookieName,
            "",
            -1,
            "/",
            mw.CookieDomain,
            mw.SecureCookie,
            mw.CookieHTTPOnly,
        )
    }

    mw.LogoutResponse(c, http.StatusOK)
}

As you can see, the LogoutHandler is used to clear the Bearer authentication-related information in the cookie.

In summary, Basic authentication uses the username and password for authentication and is usually used in the login interface /login. After the user logs in successfully, a JWT token is returned, which is then saved in the browser’s cookie or localStorage for subsequent requests.

For subsequent requests, the token is always included to complete the Bearer authentication. In addition, there are usually corresponding /logout and /refresh interfaces for logout and token refreshing.

You may wonder why token refreshing is needed. This is because the token issued by the login interface has an expiration time. With the refresh interface, the frontend can refresh the expiration time of the token according to its needs. The expiration time can be specified by the jwt.timeout configuration item in the iam-apiserver configuration file. The secret key (secretKey) used when issuing the token during login is specified by the jwt.key configuration item.

How is Bearer authentication implemented in the IAM project? #

Earlier, we introduced Basic authentication. Here, I will introduce how Bearer authentication is implemented in the IAM project.

There are two parts in the IAM project that implement Bearer authentication: iam-apiserver and iam-authz-server. Let me explain how they each implement Bearer authentication.

Implementation of Bearer authentication in iam-authz-server #

Let’s start by looking at how iam-authz-server implements Bearer authentication.

iam-authz-server uses the cache authentication middleware by loading it in the /v1 route group:

auth := newCacheAuth()
apiv1 := g.Group("/v1", auth.AuthFunc())

Let’s take a look at the newCacheAuth function:

func newCacheAuth() middleware.AuthStrategy {
    return auth.NewCacheStrategy(getSecretFunc())
}

func getSecretFunc() func(string) (auth.Secret, error) {
    return func(kid string) (auth.Secret, error) {
        cli, err := store.GetStoreInsOr(nil)
        if err != nil {
            return auth.Secret{}, errors.Wrap(err, "get store instance failed")
        }

        secret, err := cli.GetSecret(kid)
        if err != nil {
            return auth.Secret{}, err
        }

        return auth.Secret{
            Username: secret.Username,
            ID:       secret.SecretId,
            Key:      secret.SecretKey,
            Expires:  secret.Expires,
        }, nil
    }
}

The newCacheAuth function calls auth.NewCacheStrategy to create a cache authentication strategy, passing the getSecretFunc function as a parameter. The getSecretFunc function returns the secret key information. The secret key information contains the following fields:

type Secret struct {
    Username string
    ID       string
    Key      string
    Expires  int64
}

Now let’s take a look at the AuthFunc method implemented by the cache authentication strategy: source

func (cache CacheStrategy) AuthFunc() gin.HandlerFunc {
	return func(c *gin.Context) {
		header := c.Request.Header.Get("Authorization")
		if len(header) == 0 {
			core.WriteResponse(c, errors.WithCode(code.ErrMissingHeader, "Authorization header cannot be empty."), nil)
			c.Abort()

			return
		}

		var rawJWT string
		// Parse the header to get the token part.
		fmt.Sscanf(header, "Bearer %s", &rawJWT)

		// Use own validation logic, see below
		var secret Secret

		claims := &jwt.MapClaims{}
		// Verify the token
		parsedT, err := jwt.ParseWithClaims(rawJWT, claims, func(token *jwt.Token) (interface{}, error) {
			// Validate the alg is HMAC signature
			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
			}

			kid, ok := token.Header["kid"].(string)
			if !ok {
				return nil, ErrMissingKID
			}

			var err error
			secret, err = cache.get(kid)
			if err != nil {
				return nil, ErrMissingSecret
			}

			return []byte(secret.Key), nil
		}, jwt.WithAudience(AuthzAudience))
		if err != nil || !parsedT.Valid {
			core.WriteResponse(c, errors.WithCode(code.ErrSignatureInvalid, err.Error()), nil)
			c.Abort()

			return
		}

		if KeyExpired(secret.Expires) {
			tm := time.Unix(secret.Expires, 0).Format("2006-01-02 15:04:05")
			core.WriteResponse(c, errors.WithCode(code.ErrExpired, "expired at: %s", tm), nil)
			c.Abort()

			return
		}

		c.Set(CtxUsername, secret.Username)
		c.Next()
	}
}

// KeyExpired checks if a key has expired, if the value of user.SessionState.Expires is 0, it will be ignored.
func KeyExpired(expires int64) bool {
	if expires >= 1 {
		return time.Now().After(time.Unix(expires, 0))
	}

	return false
}

The AuthFunc function completes the JWT authentication in four steps, each step consisting of several sub-steps. Let’s go through them together.

Step 1: Retrieve XX.YY.ZZ from the “Authorization: Bearer XX.YY.ZZ” request header. XX.YY.ZZ represents the JWT Token.

Step 2: Call the ParseWithClaims function provided by the github.com/dgrijalva/jwt-go package, which performs the following four steps:

  • Call the ParseUnverified function, which performs the following operations:

    • Retrieve the first part XX from the token, decode it using base64, and obtain the JWT Token’s Header {"alg":"HS256","kid":"a45yPqUnQ8gljH43jAGQdRo0bXzNLjlU0hxa","typ":"JWT"}.
    • Retrieve the second part YY from the token, decode it using base64, and obtain the JWT Token’s Payload {"aud":"iam.authz.marmotedu.com","exp":1625104314,"iat":1625097114,"iss":"iamctl","nbf":1625097114}.
    • Retrieve the token’s encryption function based on the alg field in the token header.
    • The ParseUnverified function will ultimately return a token variable of type *jwt.Token. This token contains important fields such as Method, Header, Claims, and Valid, which are used in the subsequent authentication steps.
  • Call the keyFunc parameter to retrieve the secret key. Here’s an implementation of the keyFunc:

func(token *jwt.Token) (interface{}, error) {
	// Validate the alg is HMAC signature
	if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
		return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
	}

	kid, ok := token.Header["kid"].(string)
	if !ok {
		return nil, ErrMissingKID
	}

	var err error
	secret, err = cache.get(kid)
	if err != nil {
		return nil, ErrMissingSecret
	}

	return []byte(secret.Key), nil
}

You can see that keyFunc receives a variable of type *Token and retrieves the kid from the Token Header, where kid is the key ID: secretID. Then, it calls cache.get(kid) to retrieve the secretKey. The cache.get function is actually getSecretFunc, which searches for the key information in memory based on the kid. The key information contains the secretKey.

  1. To obtain the Signature string ZZ from the Token, which is the third segment of the Token.
  2. After obtaining the secretKey, token.Method.Verify verifies if the Signature string ZZ, which is the third segment of the Token, is valid. token.Method.Verify actually uses the same encryption algorithm and the same secretKey to encrypt the string XX.YY. Let’s assume the encrypted string is WW. Next, WW is compared with the base64-decoded string of ZZ. If they are equal, the authentication is passed. Otherwise, the authentication fails.

Step 3: Call KeyExpired to verify if the secret has expired. The secret information includes the expiration time, and you only need to compare it with the current time.

Step 4: Set the HTTP Header username: colin.

With this, the analysis of the Bearer authentication process in iam-authz-server is complete.

Let’s summarize: iam-authz-server applies Bearer authentication by loading it as a Gin middleware when accessing the /v1/authz interface. Bearer authentication has the advantages of an expiration time and the ability to carry more useful information in the authentication string. It also provides irreversible encryption. Therefore, Bearer authentication and the JWT format for Tokens, which are the most popular authentication method in the industry for API authentication, are adopted for /v1/authz.

Bearer authentication requires secretID and secretKey, which are obtained through gRPC API calls from iam-apisaerver and cached in memory in iam-authz-server for authentication queries.

When a request arrives, the Bearer authentication middleware in iam-authz-server parses the Header from the JWT Token and retrieves the secretID from the kid field of the Header. Then, it searches for the secretKey based on the secretID and encrypts the Header and Payload of the JWT Token using the secretKey. Finally, it compares the result with the Signature section. If they match, the authentication is passed; otherwise, the authentication fails.

Implementation of Bearer Authentication in iam-apiserver #

Now let’s look at the Bearer authentication in iam-apiserver.

Bearer authentication in iam-apiserver is specified by the following code (located in the router.go file at line 65):

v1.Use(auto.AuthFunc())

Let’s take a look at the implementation of auto.AuthFunc() in auto.go:

func (a AutoStrategy) AuthFunc() gin.HandlerFunc {
	return func(c *gin.Context) {
		operator := middleware.AuthOperator{}
		authHeader := strings.SplitN(c.Request.Header.Get("Authorization"), " ", 2)

		if len(authHeader) != authHeaderCount {
			core.WriteResponse(
				c,
				errors.WithCode(code.ErrInvalidAuthHeader, "Authorization header format is wrong."),
				nil,
			)
			c.Abort()

			return
		}

		switch authHeader[0] {
		case "Basic":
			operator.SetStrategy(a.basic)
		case "Bearer":
			operator.SetStrategy(a.jwt)
			// a.JWT.MiddlewareFunc()(c)
		default:
			core.WriteResponse(c, errors.WithCode(code.ErrSignatureInvalid, "unrecognized Authorization header."), nil)
			c.Abort()

			return
		}

		operator.AuthFunc()(c)

		c.Next()
	}
}

From the above code, we can see that the AuthFunc function parses the authentication method (either Basic or Bearer) from the Authorization Header. If it is Bearer, it uses the JWT authentication strategy; if it is Basic, it uses the Basic authentication strategy.

Next, let’s look at the implementation of the AuthFunc function for the JWT strategy in jwt.go:

func (j JWTStrategy) AuthFunc() gin.HandlerFunc {
	return j.MiddlewareFunc()
}

By following the code, we can find that the MiddlewareFunc function eventually calls the middlewareImpl method of the GinJWTMiddleware struct from the github.com/appleboy/gin-jwt package auth_jwt.go:

func (mw *GinJWTMiddleware) middlewareImpl(c *gin.Context) {
	claims, err := mw.GetClaimsFromJWT(c)
	if err != nil {
		mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c))
		return
	}

	if claims["exp"] == nil {
		mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrMissingExpField, c))
		return
	}

	if _, ok := claims["exp"].(float64); !ok {
		mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrWrongFormatOfExp, c))
		return
	}

	if int64(claims["exp"].(float64)) < mw.TimeFunc().Unix() {
		mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrExpiredToken, c))
		return
	}

	c.Set("JWT_PAYLOAD", claims)
	identity := mw.IdentityHandler(c)

	if identity != nil {
		c.Set(mw.IdentityKey, identity)
	}

	if !mw.Authorizator(identity, c) {
		mw.unauthorized(c, http.StatusForbidden, mw.HTTPStatusMessageFunc(ErrForbidden, c))
		return
	}

	c.Next()
}

Analyzing the above code, we can understand the Bearer authentication process of middlewareImpl:

Step 1: Calls the GetClaimsFromJWT function to retrieve the Authorization Header from the HTTP request, parse the Token string, and perform authentication. Finally, it returns the Token Payload.

Step 2: Validates if the exp in the Payload has exceeded the current time. If it has, the Token has expired and the validation fails.

Step 3: Adds the JWT_PAYLOAD key to the gin.Context for later use in subsequent code (although it might not be necessary).

Step 4: Adds the IdentityKey key to the gin.Context through the following code. The IdentityKey key can be specified when creating the GinJWTMiddleware struct. In this case, it is set to middleware.UsernameKey, which is the username.

identity := mw.IdentityHandler(c)

if identity != nil {
	c.Set(mw.IdentityKey, identity)
}

The value of the IdentityKey key is returned by the IdentityHandler function:

func(c *gin.Context) interface{} {
	claims := jwt.ExtractClaims(c)

	return claims[jwt.IdentityKey]
}

The above function retrieves the value of the identity field from the Token’s Payload. The value of the identity field is actually the username, and you can check the payloadFunc function for more information.

Step 5: Calls the Authorizator method. Authorizator is a callback function that must return true for success and false for failure. Authorizator is also specified when creating the GinJWTMiddleware. For example:

func authorizator() func(data interface{}, c *gin.Context) bool {    
    return func(data interface{}, c *gin.Context) bool {    
        if v, ok := data.(string); ok {    
            log.L(c).Infof("user `%s` is authenticated.", v)         
                                                                     
            return true                            
        }                                                        
                                                                 
        return false                     
    }    
}

The authorizator function returns an anonymous function. When authentication is successful, the anonymous function logs a successful authentication message.

Tips for Designing Authentication Functionality in IAM Projects #

When designing the authentication functionality in an IAM project, I also employed some techniques, which I would like to share with you here.

Tip 1: Interface-Oriented Programming #

When using the NewAutoStrategy function to create an auto authentication strategy, a parameter of type middleware.AuthStrategy interface is passed, which means that different implementations of Basic and Bearer authentication can be used, allowing for the extension of new authentication methods as needed.

Tip 2: Using Abstract Factory Pattern #

In the auth.go file, when creating authentication strategies using newBasicAuth, newJWTAuth, and newAutoAuth, interfaces are returned. By returning interfaces, various authentication functionalities provided by you can be used by callers without exposing the internal implementation.

Tip 3: Using Strategy Pattern #

In the auto authentication strategy, we choose and set the authentication strategy (Basic or Bearer) based on the Authorization: XXX X.Y.X HTTP request header. You can check the AuthFunc function of AutoStrategy for details:

func (a AutoStrategy) AuthFunc() gin.HandlerFunc {
    return func(c *gin.Context) {
        operator := middleware.AuthOperator{}
        authHeader := strings.SplitN(c.Request.Header.Get("Authorization"), " ", 2)
        ...
        switch authHeader[0] {
        case "Basic":
            operator.SetStrategy(a.basic)
        case "Bearer":
            operator.SetStrategy(a.jwt)
            // a.JWT.MiddlewareFunc()(c)
        default:
            core.WriteResponse(c, errors.WithCode(code.ErrSignatureInvalid, "unrecognized Authorization header."), nil)
            c.Abort()

            return
        }

        operator.AuthFunc()(c)

        c.Next()
    }
}

In the above code, if it is Basic, it is set as the Basic authentication method operator.SetStrategy(a.basic); if it is Bearer, it is set as the Bearer authentication method operator.SetStrategy(a.jwt). The SetStrategy method takes a parameter of type AuthStrategy interface, which implements the AuthFunc() gin.HandlerFunc function for authentication. Therefore, we can complete the authentication by calling operator.AuthFunc()(c).

Summary #

In the IAM project, iam-apiserver implements Basic authentication and Bearer authentication, while iam-authz-server implements Bearer authentication. This lecture focuses on the authentication implementation of iam-apiserver.

To access iam-apiserver, users need to first pass Basic authentication. Once authenticated, a JWT Token and its expiration time will be returned. The front-end stores the Token in LocalStorage or Cookie, and all subsequent requests are authenticated using this Token.

During Basic authentication, iam-apiserver parses the username and password from the HTTP Authorization Header, encrypts the password again, and compares it with the saved value in the database. If they do not match, the authentication fails; otherwise, it succeeds. After successful authentication, a Token is returned, with the username set in the Payload section using “username” as the key.

During Bearer authentication, iam-apiserver parses the Header and Payload from the JWT Token and retrieves the encryption algorithm from the Header. Then, it encrypts Header.Payload again using the obtained encryption algorithm and the secret key obtained from the configuration file to obtain the Signature. The two Signatures are compared. If they do not match, an HTTP 401 Unauthorized error is returned. If they match, the Token’s expiration is checked. If it has expired, authentication is not passed; otherwise, it is passed. After successful authentication, the username from the Payload is added to a gin.Context variable for subsequent business logic to use.

I have created a diagram illustrating the entire process, which you can refer to for review.

Exercise #

  1. Read the GetClaimsFromJWT method of the GinJWTMiddleware structure in the github.com/appleboy/gin-jwt package. Analyze how the GetClaimsFromJWT method extracts and authenticates the token from the gin.Context.
  2. Consider whether iam-apiserver and iam-authzserver can use the same authentication strategy. If so, how can it be implemented?

Feel free to leave a comment in the section below to discuss and exchange ideas. See you in the next lesson.