About: Software Engineer experienced in building applications using Python, Rust (Web), Go, and JavaScript/TypeScript. I am actively looking for new job opportunities.
Location:
USA
Joined:
Jun 25, 2020
Authentication system using Golang and Sveltekit - Profile update and app metric
Publish Date: Jun 6 '23
2 0
Introduction
Users of our application might make mistakes while filling out the registration form. Or they might change their names. To not bore our users with not-so-important details at the registration stage, we omitted some fields such as thumbnail, github_link, birth_date, and phone_number. We need to provide an interface for our users to update these fields. For picture uploads, we will use AWS S3. Also, as developers, it is important for us to quickly know how and when to increase or reduce our application's infrastructure. We will use Golang's expvar and httpsnoop to provide such an interface.
Source code
The source code for this series is hosted on GitHub via:
In order to use AWS S3 to handle our system's file uploads, we need to build some foundations. Using AWS in Golang, as in many other languages, requires installing its SDK. Parts of the SDK are general and required while others are need-based. Therefore, let's first install the core SDK and the config modules:
~/Documents/Projects/web/go-auth/go-auth-backend$ go get github.com/aws/aws-sdk-go-v2
~/Documents/Projects/web/go-auth/go-auth-backend$ go get github.com/aws/aws-sdk-go-v2/config
Then the AWS service API client for S3:
~/Documents/Projects/web/go-auth/go-auth-backend$ go get github.com/aws/aws-sdk-go-v2/service/s3
The above additions added some details to the config type. Using them, we built an AWS SDK config instance which was later used to initialize an AWS S3 API client.
The application won't compile yet since we haven't really loaded these credentials in the config. We will do that in cmd/api/config.go:
The details were loaded from our app's .env file. Also, we are hard-coding s3_key_prefix in this case but you can make it dynamic if you wish.
Now, we can use these configurations to upload and delete files.
Step 2: Uploading and deleting files from AWS S3
Our design decision will be to have different endpoints to upload and delete files from S3. But before the endpoints, we'll abstract away the upload and delete logic. The logic will live in cmd/api/s3_utils.go:
// cmd/api/s3_utils.gopackagemainimport("context""crypto/rand""encoding/base32""fmt""net/http""strings""github.com/aws/aws-sdk-go-v2/aws""github.com/aws/aws-sdk-go-v2/service/s3""github.com/aws/aws-sdk-go-v2/service/s3/types")func(app*application)uploadFileToS3(r*http.Request)(*string,error){file,handler,err:=r.FormFile("thumbnail")iferr!=nil{app.logError(r,err)returnnil,err}deferfile.Close()b:=make([]byte,16)_,err=rand.Read(b)iferr!=nil{app.logError(r,err)returnnil,err}// Encode bytes in base32 without the trailing ==s:=base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)fileName:=fmt.Sprintf("%s_%s",s,handler.Filename)key:=fmt.Sprintf("%s%s",app.config.awsConfig.s3_key_prefix,fileName)_,err=app.S3Client.PutObject(context.Background(),&s3.PutObjectInput{Bucket:aws.String(app.config.awsConfig.BucketName),Key:aws.String(key),Body:file,})iferr!=nil{app.logError(r,err)returnnil,err}s3_url:=fmt.Sprintf("%s/%s",app.config.awsConfig.BaseURL,key)return&s3_url,nil}func(app*application)deleteFileFromS3(r*http.Request)(bool,error){thumbnailURL:=r.FormValue("thumbnail_url")varobjectIds[]types.ObjectIdentifierword:="media"substrings:=strings.Split(thumbnailURL,word)key:=fmt.Sprintf("%s%s",word,substrings[len(substrings)-1])objectIds=append(objectIds,types.ObjectIdentifier{Key:aws.String(key)})_,err:=app.S3Client.DeleteObjects(context.Background(),&s3.DeleteObjectsInput{Bucket:aws.String(app.config.awsConfig.BucketName),Delete:&types.Delete{Objects:objectIds},})iferr!=nil{app.logError(r,err)returnfalse,err}returntrue,nil}
In uploadFileToS3, we used Go's request.FormFile() to retrieve the file coming from the request's FormData by name. This is a way to get uploaded files from .FormFile(). The method returns three items: the file, handler and err. The file holds the uploaded file itself while handler holds the file details such as name, size and so on. You can check this article for ways to handle FormData in Golang. Next, I don't want files with the same name to be overwritten so with each file, we prepend encoded randomly generated bytes to the filename. Therefore, the filenames have texts prepended to them. Then, we used AWS S3 API Client's PutObject to upload the file.
As for the deleteFileFromS3, we require that users supply their images' URLs. In our app, the URL will be automatically extracted from the users. Using the URL, we trimmed off its beginning until media is seen. For example, if an image URL is https://bucket_name.s3.origin.amazonaws.com/media/go-auth/name_of_image.png, after trimming, we will be left with media/go-auth/name_of_image.png. Natively, AWS S3 supports bulk deletion of objects, we supplied the shortened URL as the object's objectId and used DeleteObjects to delete it. That's it!
Now, the handlers will be dead simple as almost all the major things have been abstracted!
// cmd/api/upload_image_s3.gopackagemainimport("errors""net/http")func(app*application)uploadFileToS3Handler(whttp.ResponseWriter,r*http.Request){_,status,err:=app.extractParamsFromSession(r)iferr!=nil{switch*status{casehttp.StatusUnauthorized:app.unauthorizedResponse(w,r,err)casehttp.StatusBadRequest:app.badRequestResponse(w,r,errors.New("invalid cookie"))casehttp.StatusInternalServerError:app.serverErrorResponse(w,r,err)default:app.serverErrorResponse(w,r,errors.New("something happened and we could not fullfil your request at the moment"))}return}s3URL,err:=app.uploadFileToS3(r)iferr!=nil{app.badRequestResponse(w,r,err)return}env:=envelope{"s3_url":s3URL}err=app.writeJSON(w,http.StatusOK,env,nil)iferr!=nil{app.serverErrorResponse(w,r,err)}app.logSuccess(r,http.StatusOK,"Image uploaded successfully")}
The upload handler should be very familiar. We only allowed authenticated users to upload files and after a successful process, returned the URL of the uploaded file.
// cmd/api/delete_image_s3.gopackagemainimport("errors""net/http")func(app*application)deleteFileOnS3Handler(whttp.ResponseWriter,r*http.Request){_,status,err:=app.extractParamsFromSession(r)iferr!=nil{switch*status{casehttp.StatusUnauthorized:app.unauthorizedResponse(w,r,err)casehttp.StatusBadRequest:app.badRequestResponse(w,r,errors.New("invalid cookie"))casehttp.StatusInternalServerError:app.serverErrorResponse(w,r,err)default:app.serverErrorResponse(w,r,errors.New("something happened and we could not fullfil your request at the moment"))}return}_,err=app.deleteFileFromS3(r)iferr!=nil{app.badRequestResponse(w,r,err)return}app.successResponse(w,r,http.StatusNoContent,"Image deleted successfully.")}
deleteFileOnS3Handler is almost the same aside from the fact that we didn't return the file's URL but a success message instead!
Step 3: User profile update
Now the handler that updates users' data:
// cmd/api/update_user.gopackagemainimport("errors""net/http""goauthbackend.johnowolabiidogun.dev/internal/data""goauthbackend.johnowolabiidogun.dev/internal/types""goauthbackend.johnowolabiidogun.dev/internal/validator")func(app*application)updateUserHandler(whttp.ResponseWriter,r*http.Request){userID,status,err:=app.extractParamsFromSession(r)iferr!=nil{switch*status{casehttp.StatusUnauthorized:app.unauthorizedResponse(w,r,err)casehttp.StatusBadRequest:app.badRequestResponse(w,r,errors.New("invalid cookie"))casehttp.StatusInternalServerError:app.serverErrorResponse(w,r,err)default:app.serverErrorResponse(w,r,errors.New("something happened and we could not fullfil your request at the moment"),)}return}db_user,err:=app.models.Users.Get(userID.Id)iferr!=nil{app.badRequestResponse(w,r,err)return}varinputstruct{FirstName*string`json:"first_name"`LastName*string`json:"last_name"`Thumbnail*string`json:"thumbnail"`PhoneNumber*string`json:"phone_number"`BirthDatetypes.NullTime`json:"birth_date"`GithubLink*string`json:"github_link"`}err=app.readJSON(w,r,&input)iferr!=nil{app.badRequestResponse(w,r,err)return}ifinput.FirstName!=nil{db_user.FirstName=*input.FirstName}ifinput.LastName!=nil{db_user.LastName=*input.LastName}ifinput.Thumbnail!=nil{db_user.Thumbnail=input.Thumbnail}ifinput.PhoneNumber!=nil{db_user.Profile.PhoneNumber=input.PhoneNumber}ifinput.BirthDate.Valid{db_user.Profile.BirthDate=input.BirthDate}ifinput.GithubLink!=nil{db_user.Profile.GithubLink=input.GithubLink}v:=validator.New()ifdata.ValidateUser(v,db_user);!v.Valid(){app.failedValidationResponse(w,r,v.Errors)return}updated_user,err:=app.models.Users.Update(db_user)iferr!=nil{app.serverErrorResponse(w,r,err)return}err=app.writeJSON(w,http.StatusOK,updated_user,nil)iferr!=nil{app.serverErrorResponse(w,r,err)}app.logSuccess(r,http.StatusOK,"User updated successfully")}
Since this handler will allow PATCH HTTP method, users are allowed to supply any field they want updated using the Update method on the UserModel:
Before we go, we need an endpoint to "instrument" our application.
Step 4: Getting the app's metrics
To get application's metrics, we'll use primarily expvar and, just for recording HTTP Status codes, httpsnoop. To start with, this endpoint should be heavily protected as hackers can take advantage of the data it exposes to attack, using a Denial of Service attack, our application. As a result of this, we will write a middleware that only allows superuser(s), who in most applications should be only one person, to access the endpoint:
// cmd/api/middleware.go...func(app*application)authenticateAndAuthorize(nexthttp.Handler)http.Handler{returnhttp.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){userID,status,err:=app.extractParamsFromSession(r)iferr!=nil{switch*status{casehttp.StatusUnauthorized:app.unauthorizedResponse(w,r,err)casehttp.StatusBadRequest:app.badRequestResponse(w,r,errors.New("invalid cookie"))casehttp.StatusInternalServerError:app.serverErrorResponse(w,r,err)default:app.serverErrorResponse(w,r,errors.New("something happened and we could not fullfil your request at the moment"),)}return}// Get session from redis_,err=app.getFromRedis(fmt.Sprintf("sessionid_%s",userID.Id))iferr!=nil{app.unauthorizedResponse(w,r,errors.New("you are not authorized to access this resource"))return}db_user,err:=app.models.Users.Get(userID.Id)iferr!=nil{app.badRequestResponse(w,r,err)return}if!db_user.IsSuperuser{app.unauthorizedResponse(w,r,errors.New("you are not authorized to access this resource"))return}next.ServeHTTP(w,r)})}
To know more about middleware, kindly go through this article.
In the middleware, we only allowed authenticated users who have is_superuser set to true to access the endpoint.
We simply wrapped the default metrics hander, expvar.Handler() with the newly created middleware. Since the default data exposed by this endpoint ain't enough, we will register more data such as database connection information in cmd/api/main.go:
The application's version, number of goroutines, database statistics, and current timestamp in Unix format were added. Next, we need to get requests and response metrics. A middleware will also help here. Using this opportunity, we can bring in httpsnoop just for HTTP status codes and how many times they were returned:
...import(..."expvar"..."github.com/felixge/httpsnoop")...func(app*application)metrics(nexthttp.Handler)http.Handler{totalRequestsReceived:=expvar.NewInt("total_requests_received")totalResponsesSent:=expvar.NewInt("total_responses_sent")totalProcessingTimeMicroseconds:=expvar.NewInt("total_processing_time_μs")totalResponsesSentByStatus:=expvar.NewMap("total_responses_sent_by_status")returnhttp.HandlerFunc(func(whttp.ResponseWriter,r*http.Request){totalRequestsReceived.Add(1)metrics:=httpsnoop.CaptureMetrics(next,w,r)// Only place `httpsnoop` is neededtotalResponsesSent.Add(1)totalProcessingTimeMicroseconds.Add(metrics.Duration.Microseconds())totalResponsesSentByStatus.Add(strconv.Itoa(metrics.Code),1)})}...
Now, we can wrap our entire routes with this middleware:
With that, all the features of our backend system have been added.
NOTE: The code in the repo has an additional feature which ensures that if our application is abruptly interrupted, it will wait for backend tasks and pending requests to be fulfilled before stopping. You can check that out.
In the next one, we will build out the remaining front-end codes.
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!