AWS Cognito API for Golang Authentication

0_w7YvGDTjdy58AzxB.jpeg

This article will contain a lot of code snippets. All code is retrievable here: https://github.com/fulviodenza/cognito-golang
This is not production-ready code, you should add some other checks, wrap errors from Cognito to make them less user-affordable, integrate Cognito with Amplify and other things we’re not interested in.

Writing an Authentication service for a Golang project using AWS Cognito is quite simple if you know where to search for information.

Let’s start from the beginning.

Set up Cognito

What is AWS Cognito?

Amazon Cognito provides authentication, authorization, and user management for your web and mobile apps. Your users can sign in directly with a user name and password, or through a third party such as Facebook, Amazon, Google or Apple.

In this article, we’ll talk about how to perform various operations just with username and password using the AWS SDK library (https://github.com/aws/aws-sdk-go) and the Echo web framework.

First of all, let’s create a User Pool, a user pool is a user directory in Amazon Cognito, this will contain all information to access your web application.

From the AWS reach the Cognito page and Create User Pool as shown below:

After this step, we’ll have to proceed with some configurations:

  1. Configure Sign-in experience

2. Next we’ll have to select Password Policy, MFA and User account Recovery, select your preferred configurations.
In this guide we’ll use the following configuration:
Password Policy: Cognito Default
Multi-factor authentication: Authenticator Apps
User Account Recovery: Enable self-service account recovery — Recommended (Email Only)

3. Configure sign-up experience: Let’s keep it as is (Email verification.

4. Configure message delivery: Let’s select “Send Email with Cognito” and create a new IAM Role called “email-cognito"

5. Integrate your app: Now we should decide the user pool name, userpool-example, the type of Client, Public Client, the app client name, appclient-example.

6. Create User Pool!

Types definition

Now we’re almost ready to go, user pool has been created:

Let’s step onto Go code, starting with types definition:

                type (
	// App struct provides basic information to connect to the
	// Cognito UserPool on AWS.
	App struct {
		CognitoClient   *cognito.CognitoIdentityProvider
		UserPoolID      string
		AppClientID     string
		AppClientSecret string
		Token           string
	}

	User struct {
		// Username is the username decided by the user
		// at signup time. This field is not required but it could
		// be useful to have
		Username string `json:"username" validate:"required" db:"username"`

		// Password is the password decided by the user
		// at signup time. This field is required and no signup
		// can work without this.
		// To create a secure password, contraints on this field are
		// it must contain an uppercase and lowercase letter,
		// a special symbol and a number.
		Password string `json:"password" validate:"required"`
	}
	UserForgot struct {
		Username string `json:"username" validate:"required"`
	}

	UserConfirmationCode struct {
		ConfirmationCode string `json:"confirmationCode" validate:"required"`
		User             User   `json:"user" validate:"required"`
	}
	UserRegister struct {
		Email string `json:"email" validate:"required" db:"email"`
		User  User   `json:"user" validate:"required"`
	}

	// OTP is the struct to handle otp verification.
	OTP struct {
		// Username is the user's username, this is necessary because
		// cognito.ConfirmSignUpInput structure requires this field
		Username string `json:"username"`

		// OTP is the otp code received via email or phone.
		OTP string `json:"otp"`
	}

	// Response is used to handle and return errors from the server.
	// These errors could come from AWS or Server side.
	Response struct {
		Error error `json:"error"`
	}

	CustomValidator struct {
		validator *validator.Validate
	}
)
            

Ok, now things get complex.
I’ll define each struct what does it mean:

The App struct contains each key useful for the user pool connection. Each of these keys is retrievable from the AWS console. These keys should be kept privates and not shared with anyone.

The User struct contains fields for user recognizing. Fields self-explanatory
UserRegister and UserConfirmationCode extend User struct adding a field for Email registration (for the first one) and a field for OTP verification at Forgot Password time (for the second one).

UserForgot instead is used for Forgot Password operation. It requires just the Username field, the password is (obviously) not required.

Finally, the OTP type is used for user verification at login time. This requires just the OTP received via mail and the username the user is logging.

Response and CustomValidator are used for the Echo framework and are not required for Cognito authentication.

Function Definition

Helper functions

We’ll see these 3 recurrent functions:

                func (cv *CustomValidator) Validate(i interface{}) error {
	if err := cv.validator.Struct(i); err != nil {
		// Optionally, you could return the error to give each route more control over the status code
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}
	return nil
}

func validateUser(sl validator.StructLevel) {
	if len(sl.Current().Interface().(User).Username) == 0 || len(sl.Current().Interface().(User).Password) == 0 {
		sl.ReportError(sl.Current().Interface(), "User", "", "", "")
	}
}

func computeSecretHash(clientSecret string, username string, clientId string) string {
	mac := hmac.New(sha256.New, []byte(clientSecret))
	mac.Write([]byte(username + clientId))

	return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
            

Validate: validates the JSON in the user request body
validateUser: validates the nested structure User in the UserRegister and UserConfirmationCode
computeSecretHash: Given the secret token, calculates the hash

Registration

                func (a *App) Register(c echo.Context, v validator.Validate) (err error) {

	r := new(Response)
	u := new(UserRegister)

	// Bind the user input saved in context to the u(User) variable and validate it
	if err = c.Bind(u); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}
	if err = c.Validate(u); err != nil {
		return err
	}
	if err = v.Struct(u.User); err != nil {
		return err
	}

	user := &cognito.SignUpInput{
		Username: aws.String(u.User.Username),
		Password: aws.String(u.User.Password),
		ClientId: aws.String(a.AppClientID),
		UserAttributes: []*cognito.AttributeType{
			{
				Name:  aws.String("email"),
				Value: aws.String(u.Email),
			},
		},
	}

	secretHash := computeSecretHash(a.AppClientSecret, u.User.Username, a.AppClientID)
	user.SecretHash = aws.String(secretHash)

	// Make signup operation using cognito's api
	_, r.Error = a.CognitoClient.SignUp(user)
	if r.Error != nil {
		return c.JSON(http.StatusInternalServerError, r)
	}

	return c.JSON(http.StatusOK, r)
}
            

The register receiver method is the handler for the /register endpoint, this method takes echo.Context in which is located the user input as JSON request as input with the following format:

                { 
  “email”:”example@email.com”, 
  “user”:{ 
    “username”:”exampleusername”, 
    “password”:”Hello4world!” 
  } 
}
            

and returns a JSON response containing the error with the following format:

                {
  “error”: {
  “Message_”: “Example message error”
  }
}
            

Message string could also be empty.
SignUp function registers the user in the specified user pool and creates a user name, password, and user attributes. This action might generate an SMS text message.

Login

                func (a *App) Login(c echo.Context) (err error) {

  u := new(User)
	if err = c.Bind(u); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}
	if err = c.Validate(u); err != nil {
		return err
	}

	params := map[string]*string{
		"USERNAME": aws.String(u.Username),
		"PASSWORD": aws.String(u.Password),
	}

	secretHash := computeSecretHash(a.AppClientSecret, u.Username, a.AppClientID)
	params["SECRET_HASH"] = aws.String(secretHash)

	authTry := &cognito.InitiateAuthInput{
		AuthFlow: aws.String("USER_PASSWORD_AUTH"),
		AuthParameters: map[string]*string{
			"USERNAME":    aws.String(*params["USERNAME"]),
			"PASSWORD":    aws.String(*params["PASSWORD"]),
			"SECRET_HASH": aws.String(*params["SECRET_HASH"]),
		},
		ClientId: aws.String(a.AppClientID), // this is the app client ID
	}

	authResp, err := a.CognitoClient.InitiateAuth(authTry)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, authResp)
	}

	a.Token = *authResp.AuthenticationResult.AccessToken
	return c.JSON(http.StatusOK, authResp)
}
            

The login receiver method is the handler for /login endpoint, this method takes echo. Context as a parameter in which is located the user input as JSON request as input with the following format:

                {
  “username”: "exampleusername",
  "password": "Hello4world!"
}
            

and returns a JSON containing the error with this format:

                {
  “AuthenticationResult”: {
    “AccessToken”: “AccessTokenExample”,
    “ExpiresIn”: 3600,
    “IdToken”: “IdTokenExample”,
    “NewDeviceMetadata”: null,
    “RefreshToken”: “RefreshTokenExample”,
    “TokenType”: “Bearer”
  }
  “ChallengeName”: null,
  “ChallengeParameters”: {},
  “Session”: null
}
            

The function InitiateAuth function Initiates the authentication flow. This action, as described in the documentation, might generate an SMS text message.

OTP

                func (a *App) OTP(c echo.Context) (err error) {

	r := new(Response)
	o := new(OTP)
	if err = c.Bind(o); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	if err = c.Validate(o); err != nil {
		return err
	}

	user := &cognito.ConfirmSignUpInput{
		ConfirmationCode: aws.String(o.OTP),
		Username:         aws.String(o.Username),
		ClientId:         aws.String(a.AppClientID),
	}

	secretHash := computeSecretHash(a.AppClientSecret, o.Username, a.AppClientID)
	user.SecretHash = aws.String(secretHash)

	_, r.Error = a.CognitoClient.ConfirmSignUp(user)
	if err != nil {
		fmt.Println(err)
		return c.JSON(http.StatusInternalServerError, r)
	}

	return c.JSON(http.StatusOK, r)
}
            

OTP receiver method is the handler for /otp endpoint, this method takes echo.Context in which is located the user input as JSON request as input with the following format:

                {
  "username":"exampleusername",
  "otp":"123456"
}
            

otp field is received via email and returns a JSON containing the error with this format:

                {
  "error": {
    "Message_": "Example message error"
  }
}
            

ForgotPassword

                func (a *App) ForgotPassword(c echo.Context) (err error) {
	u := new(UserForgot)

	if err = c.Bind(u); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	if err = c.Validate(u); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	secretHash := computeSecretHash(a.AppClientSecret, u.Username, a.AppClientID)

	cognitoUser := &cognito.ForgotPasswordInput{
		SecretHash: aws.String(secretHash),
		ClientId:   aws.String(a.AppClientID),
		Username:   &u.Username,
	}

	cognitoUser.Validate()

	forgotPasswordOutput, err := a.CognitoClient.ForgotPassword(cognitoUser)

	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	return echo.NewHTTPError(http.StatusOK, forgotPasswordOutput)
}

func (a *App) ConfirmForgotPassword(c echo.Context, v validator.Validate) (err error) {

	u := new(UserConfirmationCode)

	if err = c.Bind(u); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	if err = c.Validate(u); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}
	if err = v.Struct(u.User); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	secretHash := computeSecretHash(a.AppClientSecret, u.User.Username, a.AppClientID)

	cognitoUser := &cognito.ConfirmForgotPasswordInput{
		SecretHash:       aws.String(secretHash),
		ClientId:         aws.String(a.AppClientID),
		Username:         &u.User.Username,
		ConfirmationCode: &u.ConfirmationCode,
		Password:         &u.User.Password,
	}

	resp, err := a.CognitoClient.ConfirmForgotPassword(cognitoUser)
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	return echo.NewHTTPError(http.StatusOK, resp)
}
            

This is the function to reset the password. ForgotPassword method triggers an email sending which will contain otp code for verifying the email.
This otp code will be submitted from the user using the below ConfirmForgotPassword function.

Logout

Logout can be performed simply by revoking the user access token in the App.Token field passing it to the function:

                func (*CognitoIdentityProvider) RevokeToken
            

Main Function

The main function is the function to initialize endpoints and set up echo stuff here is the code.

                func main() {

	// Setup The AWS Region and AWS session
	conf := &aws.Config{Region: aws.String("eu-west-1")}
	mySession := session.Must(session.NewSession(conf))

	// Fill App structure with environment keys and session generated
	a := App{
		CognitoClient:   cognito.New(mySession),
		UserPoolID:      os.Getenv("COGNITO_USER_POOL_ID"),
		AppClientID:     os.Getenv("COGNITO_APP_CLIENT_ID"),
		AppClientSecret: os.Getenv("COGNITO_APP_CLIENT_SECRET"),
	}

	// Echo stuff
	e := echo.New()
	validate := &CustomValidator{validator: validator.New()}
	validate.validator.RegisterStructValidation(validateUser, User{})

	e.Validator = validate

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())
	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
		AllowOrigins: []string{"*"},
		AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
	}))

	registerFunc := func(c echo.Context) error {
		return a.Register(c, *validate.validator)
	}
	e.POST("/auth/register", registerFunc)
	e.POST("/auth/login", a.Login)
	e.POST("/auth/otp", a.OTP)
	e.GET("/auth/forgot", a.ForgotPassword)

	confirmForgotPasswordFunc := func(c echo.Context) error {
		return a.ConfirmForgotPassword(c, *validate.validator)
	}
	e.POST("/auth/confirmforgot", confirmForgotPasswordFunc)
	e.Logger.Fatal(e.Start(":1323"))
}
            

Conclusions

Cognito is a very useful tool that, combined with tools like Amplify, could make a great difference in releasing a project which includes authentication.

I wrote this article because I noticed a lack of documentation for writing an authentication service using Golang+Cognito.

If you find any issue in this article, please notify me.


Only registered users can post comments. Please, login or signup.

Start blogging about your favorite technologies and get more readers

Join other developers and claim your FAUN account now!

Avatar

Fulvio Denza

Software Architect, Nexi

@fulviodenza
Software Architect, Go developer in my free time
Stats
39

Influence

3k

Total Hits

1

Posts

Discussed tools