Docker: The quickest way to stand up FusionAuth. (There are other ways).
While this sample application doesn't have login functionality without FusionAuth, a more typical integration will replace an existing login system with FusionAuth.
In that case, the system might look like this before FusionAuth is introduced.
Request flow during login before FusionAuth
The login flow will look like this after FusionAuth is introduced.
Request flow during login after FusionAuth
In general, you are introducing FusionAuth in order to normalize and consolidate user data. This helps make sure it is consistent and up-to-date as well as offloading your login security and functionality to FusionAuth.
In this section, you’ll get FusionAuth up and running, and configured with the ChangeBank application.
First off, grab the code from the repository and change into that directory.
git clone https://github.com/FusionAuth/fusionauth-quickstart-golang-web.git
cd fusionauth-quickstart-golang-web
You'll find a Docker Compose file (docker-compose.yml
) and an environment variables configuration file (.env
) in the root directory of the repo.
Assuming you have Docker installed, you can stand up FusionAuth on your machine with the following.
docker compose up -d
Here you are using a bootstrapping feature of FusionAuth called Kickstart. When FusionAuth comes up for the first time, it will look at the kickstart/kickstart.json
file and configure FusionAuth to your specified state.
If you ever want to reset the FusionAuth application, you need to delete the volumes created by Docker Compose by executing docker compose down -v
, then re-run docker compose up -d
.
FusionAuth will be initially configured with these settings:
e9fdb985-9173-4e01-9d73-ac2d60d1dc8e
.2HYT86lWSAntc-mvtHLX5XXEpk9ThcqZb4YEh65CLjA-not-for-prod
.richard@example.com
and the password is password
.admin@example.com
and the password is password
.http://localhost:9011/
.You can log in to the FusionAuth admin UI and look around if you want to, but with Docker and Kickstart, everything will already be configured correctly.
If you want to see where the FusionAuth values came from, they can be found in the FusionAuth app. The tenant Id is found on the Tenants page. To see the Client Id and Client Secret, go to the Applications page and click the View
icon under the actions for the ChangeBank application. You'll find the Client Id and Client Secret values in the OAuth configuration
section.
The .env
file contains passwords. In a real application, always add this file to your .gitignore
file and never commit secrets to version control.
In this section, you’ll set up a basic Golang application with a single page.
Create a new directory to hold your application, and go into it.
mkdir changebank
cd changebank
Create a go.mod file listing these dependencies:
go.mod
module main
go 1.16
require (
github.com/coreos/go-oidc/v3 v3.6.0
github.com/thanhpk/randstr v1.0.6
golang.org/x/oauth2 v0.8.0
)
Now create a base Go app, which will consist of a single file named main.go. You can either copy the code shown here, or you can copy the file /complete-application/base-app.go
from the quickstart repo and name it main.go
.
This app sets up a handful of routes that just serve a home page for now. Over the course of this quickstart you’ll be modifying each of these route handler functions to complete the integration with FusionAuth.
main.go
package main
import (
"fmt"
"html/template"
"net/http"
"path"
)
func main() {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
http.HandleFunc("/", handleMain)
http.HandleFunc("/login", handleFusionAuthLogin)
http.HandleFunc("/callback", handleFusionAuthCallback)
http.HandleFunc("/account", handleAccount)
http.HandleFunc("/logout", handleLogout)
port := "8080"
fmt.Println("Starting HTTP server at http://localhost:" + port)
fmt.Println(http.ListenAndServe(":" + port, nil))
}
func handleMain(w http.ResponseWriter, r *http.Request) {
WriteWebPage(w, "home.html", nil)
return
}
func handleFusionAuthLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
func handleFusionAuthCallback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
func handleAccount(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
}
// a function for writing a server-rendered web page
func WriteWebPage(w http.ResponseWriter, tmpl string, vars interface{}) {
fn := path.Join("templates", tmpl)
parsed_tmpl, error := template.ParseFiles(fn)
if error != nil {
http.Error(w, "Error reading template file " + tmpl + ": " + error.Error(), http.StatusInternalServerError)
return
}
if error := parsed_tmpl.Execute(w, vars); error != nil {
http.Error(w, error.Error(), http.StatusInternalServerError)
}
}
func WriteCookie(w http.ResponseWriter, name string, value string, maxAge int) {
cookie := http.Cookie{ Name: name, Domain: "localhost", Value: value, Path: "/", MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteLaxMode, }
http.SetCookie(w, &cookie)
}
And then load your dependencies and generate go.sum
go mod download all
You should now be able to start your Go application with
go run main.go
Note that you won’t be able to access it with a browser, since you haven’t created any pages yet.
The next step is to get a basic home page up and running. We’ll take this opportunity to copy in all of the static assets that you’ll need for the application, including web page templates, images, and CSS.
Run the following copy commands to copy these files from the quickstart repo into your project. This assumes that you checked the quickstart repo out into the parent directory. If that’s not the case, replace the ..
below with your actual repo location.
cp -r ../fusionauth-quickstart-go-web/complete-application/templates . && \
cp -r ../fusionauth-quickstart-go-web/complete-application/static .
With the home page template in place, you can view the home page in your browser at http://localhost:8080!
In this section, you’ll add the ability for a user to log in to your application using FusionAuth as the identity provider. To accomplish this, you’ll do the following.
/login
route to redirect the user to FusionAuth to log in/callback
route to accept the redirect from FusionAuth and to exchange an authorization code for an access token/account
route to only allow access by logged-in users/
route to detect if a user is logged in, and take them to /account
when they are/logout
endpointThe CoreOS OpenID Connect client needs to be set up so that it knows how to talk to FusionAuth.
First, create some constants that you can use throughout your program. Put these after your import
block and before func main()
.
import (
"fmt"
"html/template"
"net/http"
"net/url"
"path"
"github.com/thanhpk/randstr"
"golang.org/x/oauth2"
"github.com/coreos/go-oidc/v3/oidc"
)
If you want to see where the FusionAuth values came from, they can be found in the FusionAuth app (http://localhost:9011/admin). The tenant ID is found on the Tenants page. To see the client ID and client secret, go to the Applications page and click the View
icon under the actions for the Changebank application. You’ll find the client id and client secret values in the OAuth configuration
section.
Next, create variables for the OAuth config and the OIDC provider, and initialize them in an init()
function. Put this code after the const
block you just added.
var (
oidcProvider *oidc.Provider
fusionAuthConfig *oauth2.Config
// In a production application, we would persist a unique state string for each login request
oauthStateString string = randstr.Hex(16)
)
func init() {
// configure an OIDC provider
provider, err := oidc.NewProvider(oauth2.NoContext, FusionAuthHost)
if err != nil {
fmt.Println("Error creating OIDC provider: " + err.Error())
} else {
oidcProvider = provider
// initialize OAuth
fusionAuthConfig = &oauth2.Config{
ClientID: FusionAuthClientID,
ClientSecret: FusionAuthClientSecret,
RedirectURL: "http://localhost:8080/callback",
Endpoint: oidcProvider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "email", "offline_access"},
}
}
}
You’ll also need to add some additional imports. Modify your import
block to look like this.
package main
//tag::oidcConstants[]
import (
"fmt"
"html/template"
"net/http"
"net/url"
"path"
"github.com/thanhpk/randstr"
"golang.org/x/oauth2"
"github.com/coreos/go-oidc/v3/oidc"
)
//end::oidcConstants[]
// This supplies data to the /account page
type AccountVars struct {
LogoutUrl string
Email string
}
//tag::oidcConstants[]
const (
FusionAuthHost string = "http://localhost:9011"
FusionAuthTenantID string = "d7d09513-a3f5-401c-9685-34ab6c552453"
FusionAuthClientID string = "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e"
FusionAuthClientSecret string = "2HYT86lWSAntc-mvtHLX5XXEpk9ThcqZb4YEh65CLjA-not-for-prod"
AccessTokenCookieName string = "cb_access_token"
RefreshTokenCookieName string = "cb_refresh_token"
IDTokenCookieName string = "cb_id_token"
)
//end::oidcConstants[]
//tag::oidcClient[]
var (
oidcProvider *oidc.Provider
fusionAuthConfig *oauth2.Config
// In a production application, we would persist a unique state string for each login request
oauthStateString string = randstr.Hex(16)
)
func init() {
// configure an OIDC provider
provider, err := oidc.NewProvider(oauth2.NoContext, FusionAuthHost)
if err != nil {
fmt.Println("Error creating OIDC provider: " + err.Error())
} else {
oidcProvider = provider
// initialize OAuth
fusionAuthConfig = &oauth2.Config{
ClientID: FusionAuthClientID,
ClientSecret: FusionAuthClientSecret,
RedirectURL: "http://localhost:8080/callback",
Endpoint: oidcProvider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "email", "offline_access"},
}
}
}
//end::oidcClient[]
func main() {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
http.HandleFunc("/", handleMain)
http.HandleFunc("/login", handleLoginRequest)
http.HandleFunc("/callback", handleFusionAuthCallback)
http.HandleFunc("/account", handleAccount)
http.HandleFunc("/logout", handleLogout)
port := "8080"
fmt.Println("Starting HTTP server at http://localhost:" + port)
fmt.Println(http.ListenAndServe(":" + port, nil))
}
//tag::main[]
func handleMain(w http.ResponseWriter, r *http.Request) {
// see if the user is authenticated. In a real application, we would validate the token signature and expiration
_, err := r.Cookie(AccessTokenCookieName)
if err != nil {
WriteWebPage(w, "home.html", nil)
return
}
// The user is authenticated so redirect to /account. We redirect so that the location in the browser shows /account.
http.Redirect(w, r, "/account", http.StatusFound)
return
}
//end::main[]
//tag::loginRoute[]
func handleLoginRequest(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fusionAuthConfig.AuthCodeURL(oauthStateString), http.StatusFound)
}
//end::loginRoute[]
//tag::callbackRoute[]
func handleFusionAuthCallback(w http.ResponseWriter, r *http.Request) {
// validate the state value, to make sure this came from us
if r.FormValue("state") != oauthStateString {
http.Error(w, "Bad request - incorrect state value", http.StatusBadRequest)
return
}
// Exchange the authorization code for access, refresh, and id tokens
token, err := fusionAuthConfig.Exchange(oauth2.NoContext, r.FormValue("code"))
if err != nil {
http.Error(w, "Error getting access token: " + err.Error(), http.StatusInternalServerError)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "No ID token found in request to /callback", http.StatusBadRequest)
return
}
// Write access, refresh, and id tokens to http-only cookies
WriteCookie(w, AccessTokenCookieName, token.AccessToken, 3600, true)
WriteCookie(w, RefreshTokenCookieName, token.RefreshToken, 3600, true)
WriteCookie(w, IDTokenCookieName, rawIDToken, 3600, false)
http.Redirect(w, r, "/account", http.StatusFound)
}
//end::callbackRoute[]
//tag::accountRoute[]
func getLogoutUrl() string {
url := fmt.Sprintf("%s/oauth2/logout?client_id=%s&tenantId=%s", FusionAuthHost, url.QueryEscape(FusionAuthClientID), url.QueryEscape(FusionAuthTenantID))
return url
}
func handleAccount(w http.ResponseWriter, r *http.Request) {
// Make sure the user is authenticated. Note that in a production application, we would validate the token signature,
// make sure it wasn't expired, and attempt to refresh it if it were
cookie, err := r.Cookie(AccessTokenCookieName)
if err != nil || cookie == nil {
http.Redirect(w, r, "/", http.StatusFound)
return
}
// Now get the ID token so we can show the user's email address
cookie, err = r.Cookie(IDTokenCookieName)
if err != nil || cookie == nil{
http.Error(w, "No ID token found", http.StatusBadRequest)
return
}
var verifier = oidcProvider.Verifier(&oidc.Config{ClientID: FusionAuthClientID})
idToken, err := verifier.Verify(oauth2.NoContext, cookie.Value)
if err != nil {
http.Error(w, "Error verifying ID token: " + err.Error(), http.StatusBadRequest)
return
}
// Extract the email claim
var claims struct {
Email string `json:"email"`
}
if err := idToken.Claims(&claims); err != nil {
http.Error(w, "Error reading claims from ID token: " + err.Error(), http.StatusInternalServerError)
return
}
templateVars := AccountVars{LogoutUrl: getLogoutUrl(), Email: claims.Email}
WriteWebPage(w, "account.html", templateVars)
}
//end::accountRoute[]
//tag::logoutRoute[]
func handleLogout(w http.ResponseWriter, r *http.Request) {
// Delete the cookies we set
WriteCookie(w, AccessTokenCookieName, "", -1, true)
WriteCookie(w, RefreshTokenCookieName, "", -1, true)
WriteCookie(w, IDTokenCookieName, "", -1, false)
http.Redirect(w, r, "/", http.StatusFound)
}
//end::logoutRoute[]
// a function for writing a server-rendered web page
func WriteWebPage(w http.ResponseWriter, tmpl string, vars interface{}) {
fn := path.Join("templates", tmpl)
parsed_tmpl, error := template.ParseFiles(fn)
if error != nil {
http.Error(w, "Error reading template file " + tmpl + ": " + error.Error(), http.StatusInternalServerError)
return
}
if error := parsed_tmpl.Execute(w, vars); error != nil {
http.Error(w, error.Error(), http.StatusInternalServerError)
}
}
func WriteCookie(w http.ResponseWriter, name string, value string, maxAge int, httpOnly bool) {
cookie := http.Cookie{ Name: name, Domain: "localhost", Value: value, Path: "/", MaxAge: maxAge, HttpOnly: false, SameSite: http.SameSiteLaxMode, }
http.SetCookie(w, &cookie)
}
/login
RouteWhen the user clicks the Login
button in your Changebank app, they’ll be taken to your /login
endpoint. Have this redirect them to FusionAuth so that FusionAuth can present them with a login page. Change the handleLoginRequest
function to do that.
func handleLoginRequest(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fusionAuthConfig.AuthCodeURL(oauthStateString), http.StatusFound)
}
The Login
button on your home page will now take you to FusionAuth to log in. You can log in, but nothing will happen after you do until you add the callback handler in the next section.
After the user successfully authenticates with FusionAuth, FusionAuth will redirect the user back to your application along with an authorization code. Your application needs to exchange this code for an access token using FusionAuth’s token endpoint. This is the defining action of the OAuth code grant flow. An access token can only be acquired for a user by an application that has a valid authorization code, as well as a valid client ID and client secret.
Modify the handleFusionAuthCallback
function to do this.
func handleFusionAuthCallback(w http.ResponseWriter, r *http.Request) {
// validate the state value, to make sure this came from us
if r.FormValue("state") != oauthStateString {
http.Error(w, "Bad request - incorrect state value", http.StatusBadRequest)
return
}
// Exchange the authorization code for access, refresh, and id tokens
token, err := fusionAuthConfig.Exchange(oauth2.NoContext, r.FormValue("code"))
if err != nil {
http.Error(w, "Error getting access token: " + err.Error(), http.StatusInternalServerError)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Error(w, "No ID token found in request to /callback", http.StatusBadRequest)
return
}
// Write access, refresh, and id tokens to http-only cookies
WriteCookie(w, AccessTokenCookieName, token.AccessToken, 3600, true)
WriteCookie(w, RefreshTokenCookieName, token.RefreshToken, 3600, true)
WriteCookie(w, IDTokenCookieName, rawIDToken, 3600, false)
http.Redirect(w, r, "/account", http.StatusFound)
}
This function writes three tokens out to HTTP-only cookies. This means they aren’t available to code running in the browser, but they’ll be sent back to your go application when requests are made to the back end.
The /account
page represents what a user sees when they’re logged into their Changebank account. A user that isn’t logged in, who tries to access this page, should just be taken to the home page.
Edit the handleAccount()
function to do this. There is also a getLogoutUrl()
function that you’ll need to add.
func getLogoutUrl() string {
url := fmt.Sprintf("%s/oauth2/logout?client_id=%s&tenantId=%s", FusionAuthHost, url.QueryEscape(FusionAuthClientID), url.QueryEscape(FusionAuthTenantID))
return url
}
func handleAccount(w http.ResponseWriter, r *http.Request) {
// Make sure the user is authenticated. Note that in a production application, we would validate the token signature,
// make sure it wasn't expired, and attempt to refresh it if it were
cookie, err := r.Cookie(AccessTokenCookieName)
if err != nil || cookie == nil {
http.Redirect(w, r, "/", http.StatusFound)
return
}
// Now get the ID token so we can show the user's email address
cookie, err = r.Cookie(IDTokenCookieName)
if err != nil || cookie == nil{
http.Error(w, "No ID token found", http.StatusBadRequest)
return
}
var verifier = oidcProvider.Verifier(&oidc.Config{ClientID: FusionAuthClientID})
idToken, err := verifier.Verify(oauth2.NoContext, cookie.Value)
if err != nil {
http.Error(w, "Error verifying ID token: " + err.Error(), http.StatusBadRequest)
return
}
// Extract the email claim
var claims struct {
Email string `json:"email"`
}
if err := idToken.Claims(&claims); err != nil {
http.Error(w, "Error reading claims from ID token: " + err.Error(), http.StatusInternalServerError)
return
}
templateVars := AccountVars{LogoutUrl: getLogoutUrl(), Email: claims.Email}
WriteWebPage(w, "account.html", templateVars)
}
At this point, you should be able to successfully log into your application!
Next you’ll modify the handleMain()
function to automatically take a logged-in user to their account page.
func getLogoutUrl() string {
url := fmt.Sprintf("%s/oauth2/logout?client_id=%s&tenantId=%s", FusionAuthHost, url.QueryEscape(FusionAuthClientID), url.QueryEscape(FusionAuthTenantID))
return url
}
func handleAccount(w http.ResponseWriter, r *http.Request) {
// Make sure the user is authenticated. Note that in a production application, we would validate the token signature,
// make sure it wasn't expired, and attempt to refresh it if it were
cookie, err := r.Cookie(AccessTokenCookieName)
if err != nil || cookie == nil {
http.Redirect(w, r, "/", http.StatusFound)
return
}
// Now get the ID token so we can show the user's email address
cookie, err = r.Cookie(IDTokenCookieName)
if err != nil || cookie == nil{
http.Error(w, "No ID token found", http.StatusBadRequest)
return
}
var verifier = oidcProvider.Verifier(&oidc.Config{ClientID: FusionAuthClientID})
idToken, err := verifier.Verify(oauth2.NoContext, cookie.Value)
if err != nil {
http.Error(w, "Error verifying ID token: " + err.Error(), http.StatusBadRequest)
return
}
// Extract the email claim
var claims struct {
Email string `json:"email"`
}
if err := idToken.Claims(&claims); err != nil {
http.Error(w, "Error reading claims from ID token: " + err.Error(), http.StatusInternalServerError)
return
}
templateVars := AccountVars{LogoutUrl: getLogoutUrl(), Email: claims.Email}
WriteWebPage(w, "account.html", templateVars)
}
The last step is to implement logout. When you log a user out of an application, you’ll take them to FusionAuth’s /oauth2/logout
endpoint. After logging the user out, FusionAuth will redirect the user to your app’s /logout
endpoint, which you’ll create now. This endpoint deletes any cookies that your application created, clearing the user’s session.
Update the handleLogout()
function to do this.
func handleLogout(w http.ResponseWriter, r *http.Request) {
// Delete the cookies we set
WriteCookie(w, AccessTokenCookieName, "", -1, true)
WriteCookie(w, RefreshTokenCookieName, "", -1, true)
WriteCookie(w, IDTokenCookieName, "", -1, false)
http.Redirect(w, r, "/", http.StatusFound)
}
Click the Logout
button and watch the browser first go to FusionAuth to log out the user, then return to your home page.
This quickstart is a great way to get a proof of concept up and running quickly, but to run your application in production, there are some things you're going to want to do.
FusionAuth gives you the ability to customize just about everything to do with the user's experience and the integration of your application. This includes: