With the basic setup laid bare, it's time to build a truly useful API service for our authentication system. In this article, we will delve into user registration, storage in the database, password hashing using argon2id, sending templated emails, and generating truly random and secure tokens, among others. Let's get on!
Source code
The source code for this series is hosted on GitHub via:
We need a database table to store our application's users' data. To generate and migrate a schema, we'll use golang migrate. Kindly follow these instructions to install it on your Operating system. To create a pair of migration files (up and down) for our user table, issue the following command in your terminal and at the root of your project:
-seq instructs the CLI to use sequential numbering as against the default, which is the Unix timestamp. We opted to use .sql file extensions for the generated files by passing -ext. The generated files will live in the migrations folder we created in the previous article and -dir allows us to specify that. Lastly, we fed it with the real name of the files we want to create. You should see two files in the migrations folder by name. Kindly open the up and fill in the following schema:
-- migrations/000001_create_users_table.up.sql-- Add up migration script here-- User tableCREATETABLEIFNOTEXISTSusers(idUUIDNOTNULLPRIMARYKEYDEFAULTgen_random_uuid(),emailTEXTNOTNULLUNIQUE,passwordTEXTNOTNULL,first_nameTEXTNOTNULL,last_nameTEXTNOTNULL,is_activeBOOLEANDEFAULTFALSE,is_staffBOOLEANDEFAULTFALSE,is_superuserBOOLEANDEFAULTFALSE,thumbnailTEXTNULL,date_joinedTIMESTAMPTZNOTNULLDEFAULTNOW());CREATEINDEXIFNOTEXISTSusers_id_email_is_active_indxONusers(id,email,is_active);-- Create a domain for phone data typeCREATEDOMAINphoneASTEXTCHECK(octet_length(VALUE)BETWEEN1/*+*/+8AND1/*+*/+15+3ANDVALUE~'^\+\d+$');-- User details table (One-to-one relationship)CREATETABLEuser_profile(idUUIDNOTNULLPRIMARYKEYDEFAULTgen_random_uuid(),user_idUUIDNOTNULLUNIQUE,phone_numberphoneNULL,birth_dateDATENULL,github_linkTEXTNULL,FOREIGNKEY(user_id)REFERENCESusers(id)ONDELETECASCADE);CREATEINDEXIFNOTEXISTSusers_detail_id_user_idONuser_profile(id,user_id);
In the down file, we should have:
-- migrations/000001_create_users_table.down.sql-- Add down migration script hereDROPTABLEIFEXISTSusers;DROPTABLEIFEXISTSuser_profile;
We have been using these schemas right from when we started the authentication series.
Next, we need to execute the files so that those tables will be really created in our database:
migrate -path=./migrations -database=<DATABASE_URL> up
Ensure you replace <DATABASE_URL> with your real database URL. If everything goes well, your table should now be created in your database.
It should be noted that instead of manually migrating the database, we could do that automatically, at start-up, in the main() function.
Step 2: Setting up our user model
To abstract away interacting with the database, we will create some sort of model, an equivalent of Django's model. But before then, let's create a type for our users in internal/data/user_types.go (create the file as it doesn't exist yet):
These are just the basic types we'll be working on within this system. You will notice that there are three columns: names of the fields, field types, and the "renames" of the fields in JSON. The last column is very useful because, in Go, field names MUST start with capital letters for them to be accessible outside their package. The same goes to type names. Therefore, we need a way to properly send field names to requesting users and Go helps with that using the built-in encoding/json package. Notice also that our Password field was renamed to -. This omits that field entirely from the JSON responses it generates. How cool is that! We also defined a custom password type. This makes it easier to generate the hash of our users' passwords.
Then, there is this not-so-familiar types.NullTime in the UserProfile type. It was defined in internal/types/time.go:
// internal/types/time.gopackagetypesimport("fmt""reflect""strings""time""github.com/lib/pq")// NullTime is an alias for pq.NullTime data typetypeNullTimepq.NullTime// Scan implements the Scanner interface for NullTimefunc(nt*NullTime)Scan(valueinterface{})error{vartpq.NullTimeiferr:=t.Scan(value);err!=nil{returnerr}// if nil then make Valid falseifreflect.TypeOf(value)==nil{*nt=NullTime{t.Time,false}}else{*nt=NullTime{t.Time,true}}returnnil}// MarshalJSON for NullTimefunc(nt*NullTime)MarshalJSON()([]byte,error){if!nt.Valid{return[]byte("null"),nil}val:=fmt.Sprintf("\"%s\"",nt.Time.Format(time.RFC3339))return[]byte(val),nil}constdateFormat="2006-01-02"// UnmarshalJSON for NullTimefunc(nt*NullTime)UnmarshalJSON(b[]byte)error{t,err:=time.Parse(dateFormat,strings.Replace(string(b),"\"","",-1,))iferr!=nil{returnerr}nt.Time=tnt.Valid=truereturnnil}
The reason for this is the difficulty encountered while working with possible null values for users' birthdates. This article explains it quite well and the code above was some modification of the code there.
It should be noted that to use UUID in Go, you need an external package (we used github.com/google/uuid in our case, so install it with go get github.com/google/uuid).
We used github.com/alexedwards/argon2id package to assist in hashing and matching our users' passwords. It's Go's implementation of argon2id. The Set "method" does the hashing when a user registers whereas Matches confirms it when such a user wants to log in.
To validate users' inputs, a very good thing to do, we have:
// internal/data/user_validation.gopackagedataimport"goauthbackend.johnowolabiidogun.dev/internal/validator"funcValidateEmail(v*validator.Validator,emailstring){v.Check(email!="","email","email must be provided")v.Check(validator.Matches(email,validator.EmailRX),"email","email must be a valid email address")}funcValidatePasswordPlaintext(v*validator.Validator,passwordstring){v.Check(password!="","password","password must be provided")v.Check(len(password)>=8,"password","password must be at least 8 bytes long")v.Check(len(password)<=72,"password","password must not be more than 72 bytes long")}funcValidateUser(v*validator.Validator,user*User){v.Check(user.FirstName!="","first_name","first name must be provided")v.Check(user.LastName!="","last_name","last name must be provided")ValidateEmail(v,user.Email)// If the plaintext password is not nil, call the standalone // ValidatePasswordPlaintext() helper.ifuser.Password.plaintext!=nil{ValidatePasswordPlaintext(v,*user.Password.plaintext)}}
The code uses another custom package to validate email, password, first name, and last name — the data required during registration. The custom package looks like this:
// internal/validator/validator.gopackagevalidatorimport"regexp"varEmailRX=regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")typeValidatorstruct{Errorsmap[string]string}// New is a helper which creates a new Validator instance with an empty errors map.funcNew()*Validator{return&Validator{Errors:make(map[string]string)}}// Valid returns true if the errors map doesn't contain any entries.func(v*Validator)Valid()bool{returnlen(v.Errors)==0}// AddError adds an error message to the map (so long as no entry already exists for // the given key).func(v*Validator)AddError(key,messagestring){if_,exists:=v.Errors[key];!exists{v.Errors[key]=message}}// Check adds an error message to the map only if a validation check is not 'ok'.func(v*Validator)Check(okbool,key,messagestring){if!ok{v.AddError(key,message)}}// In returns true if a specific value is in a list of strings.funcIn(valuestring,list...string)bool{fori:=rangelist{ifvalue==list[i]{returntrue}}returnfalse}// Matches returns true if a string value matches a specific regexp pattern.funcMatches(valuestring,rx*regexp.Regexp)bool{returnrx.MatchString(value)}// Unique returns true if all string values in a slice are unique.funcUnique(values[]string)bool{uniqueValues:=make(map[string]bool)for_,value:=rangevalues{uniqueValues[value]=true}returnlen(values)==len(uniqueValues)}
Pretty easy to reason along with.
It's finally time to create the model:
// internal/data/models.gopackagedataimport("database/sql""errors")var(ErrRecordNotFound=errors.New("a user with these details was not found"))typeModelsstruct{UsersUserModel}funcNewModels(db*sql.DB)Models{returnModels{Users:UserModel{DB:db},}}
With this, if we have another model, all we need to do is register it in Models and initialize it in NewModels.
Now, we need to make this model accessible to our application. To do this, add models to our application type in main.go and initialize it inside the main() function:
That makes the models available to all route handlers and functions that implement the application type.
Step 3: User registration route handler
Let's put housekeeping to good use. Create a new file, register.go, in cmd/api and make it look like this:
// cmd/api/register.gopackagemainimport("errors""net/http""time""goauthbackend.johnowolabiidogun.dev/internal/data""goauthbackend.johnowolabiidogun.dev/internal/tokens""goauthbackend.johnowolabiidogun.dev/internal/validator")func(app*application)registerUserHandler(whttp.ResponseWriter,r*http.Request){// Expected data from the uservarinputstruct{Emailstring`json:"email"`FirstNamestring`json:"first_name"`LastNamestring`json:"last_name"`Passwordstring`json:"password"`}// Try reading the user input to JSONerr:=app.readJSON(w,r,&input)iferr!=nil{app.badRequestResponse(w,r,err)return}user:=&data.User{Email:input.Email,FirstName:input.FirstName,LastName:input.LastName,}// Hash user passworderr=user.Password.Set(input.Password)iferr!=nil{app.serverErrorResponse(w,r,err)return}// Validate the user inputv:=validator.New()ifdata.ValidateUser(v,user);!v.Valid(){app.failedValidationResponse(w,r,v.Errors)return}// Save the user in the databaseuserID,err:=app.models.Users.Insert(user)iferr!=nil{switch{caseerrors.Is(err,data.ErrDuplicateEmail):v.AddError("email","A user with this email address already exists")app.failedValidationResponse(w,r,v.Errors)default:app.serverErrorResponse(w,r,err)}return}// Generate 6-digit tokenotp,err:=tokens.GenerateOTP()iferr!=nil{app.logError(r,err)}err=app.storeInRedis("activation_",otp.Hash,userID.Id,app.config.tokenExpiration.duration)iferr!=nil{app.logError(r,err)}now:=time.Now()expiration:=now.Add(app.config.tokenExpiration.duration)exact:=expiration.Format(time.RFC1123)// Send email to user, using separate goroutine, for account activationapp.background(func(){data:=map[string]interface{}{"token":tokens.FormatOTP(otp.Secret),"userID":userID.Id,"frontendURL":app.config.frontendURL,"expiration":app.config.tokenExpiration.durationString,"exact":exact,}err=app.mailer.Send(user.Email,"user_welcome.tmpl",data)iferr!=nil{app.logError(r,err)}app.logger.PrintInfo("Email successfully sent.",nil,app.config.debug)})// Respond with successapp.successResponse(w,r,http.StatusAccepted,"Your account creation was accepted successfully. Check your email address and follow the instruction to activate your account. Ensure you activate your account before the token expires",)}
Though a bit long, reading through the lines gives you the whole idea! We expect four (4) fields from the user. After converting them to proper JSON using readJSON, a method created previously, we initialized the User type, set hash the supplied password and then validate the user-supplied data. If everything is good, we used Insert, a method on the User type that lives in internal/data/user_queries.go, to save the user in the database. The method is simple:
// internal/data/user_queries.gopackagedataimport("context""database/sql""errors""log""time""github.com/google/uuid")func(umUserModel)Insert(user*User)(*UserID,error){ctx,cancel:=context.WithTimeout(context.Background(),3*time.Second)defercancel()tx,err:=um.DB.BeginTx(ctx,nil)iferr!=nil{returnnil,err}varuserIDuuid.UUIDquery_user:=`
INSERT INTO users (email, password, first_name, last_name) VALUES ($1, $2, $3, $4) RETURNING id`args_user:=[]interface{}{user.Email,user.Password.hash,user.FirstName,user.LastName}iferr:=tx.QueryRowContext(ctx,query_user,args_user...).Scan(&userID);err!=nil{switch{caseerr.Error()==`pq: duplicate key value violates unique constraint "users_email_key"`:returnnil,ErrDuplicateEmaildefault:returnnil,err}}query_user_profile:=`
INSERT INTO user_profile (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING RETURNING user_id`_,err=tx.ExecContext(ctx,query_user_profile,userID)iferr!=nil{returnnil,err}iferr=tx.Commit();err!=nil{returnnil,err}id:=UserID{Id:userID,}return&id,nil}
We used Go's database transaction to execute our SQL queries. We also provided 3 seconds timeout for our database to finish up or get timed out! If the insertion query is successful, the user's ID is returned.
Next, we generated a token for the new user. The token is a random and cryptographically secure 6-digit number which then gets encoded using the sha252 algorithm. The entire logic is:
// internal/tokens/utils.gopackagetokensimport("crypto/rand""crypto/sha256""fmt""math/big""strings""goauthbackend.johnowolabiidogun.dev/internal/validator")typeTokenstruct{SecretstringHashstring}funcGenerateOTP()(*Token,error){bigInt,err:=rand.Int(rand.Reader,big.NewInt(900000))iferr!=nil{returnnil,err}sixDigitNum:=bigInt.Int64()+100000// Convert the integer to a string and get the first 6 characterssixDigitStr:=fmt.Sprintf("%06d",sixDigitNum)token:=Token{Secret:sixDigitStr,}hash:=sha256.Sum256([]byte(token.Secret))token.Hash=fmt.Sprintf("%x\n",hash)return&token,nil}funcFormatOTP(sstring)string{length:=len(s)half:=length/2firstHalf:=s[:half]secondHalf:=s[half:]words:=[]string{firstHalf,secondHalf}returnstrings.Join(words," ")}funcValidateSecret(v*validator.Validator,secretstring){v.Check(secret!="","token","must be provided")v.Check(len(secret)==6,"token","must be 6 bytes long")}
After the token generation, we temporarily store the token hash in redis using the storeInRedis method and then send an email, in the background using a different goroutine, to the user with instructions on how to activate their accounts. The functions used are located in cmd/api/helpers.go:
// cmd/api/helpers.go...func(app*application)storeInRedis(prefixstring,hashstring,userIDuuid.UUID,expirationtime.Duration)error{ctx:=context.Background()err:=app.redisClient.Set(ctx,fmt.Sprintf("%s%s",prefix,userID),hash,expiration,).Err()iferr!=nil{returnerr}returnnil}func(app*application)background(fnfunc()){app.wg.Add(1)gofunc(){deferapp.wg.Done()// Recover any panic.deferfunc(){iferr:=recover();err!=nil{app.logger.PrintError(fmt.Errorf("%s",err),nil,app.config.debug)}}()// Execute the arbitrary function that we passed as the parameter.fn()}()}
The tokens expire and get deleted from redis after TOKEN_EXPIRATION has elapsed.
I think we should stop here as this article is getting pretty long. In the next one, we will implement missing methods, configure our app for email sending and implement activating users' accounts handler. Enjoy!
Outro
Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!