Set up a Faroe server
A Faroe server is a server that exposes an action invocation endpoint for Faroe server actions.
This may take up to an hour depending on how comfortable you are with the language and architecture. If you want to evaluate the server first, consider using the prebuilt local server by following the Quick-start guide.
All implementations of interfaces must be thread-safe unless specified otherwise.
Main storage
The first step is to define you main storage. The main storage is where session-like data will be stored (sessions, signups, signins, user password resets, user password updates, user email address updates, and user deletion).
The main storage is a key-value store that implements MainStorageInterface
. This storage should be strongly consistent. Any writes/updates should be immediately visible to all subsequent reads. Beyond this consistency guarantee, no other requirements are imposed. You may use a traditional persistent database with backups or, if you're fine with users possible getting signed out, use a temporary storage like an in-memory store.
The entry value, counter, and optionally the expiration timestamp should be stored under each key. The counter is used to prevent race conditions. The expiration is just a hint. You may delete entries past its expiration to free up storage.
type MainStorageInterface interface {
Get(key string) ([]byte, int32, error)
Set(key string, value []byte, expiresAt time.Time) error
Update(key string, value []byte, expiresAt time.Time, counter int32) error
Delete(key string) error
}
Example implementations are available:
- MySQL with
database/sql
- PostgreSQL with
database/sql
- SQLite with
database/sql
- Basic example with Go maps
Cache storage
The cache is an optional storage for storing sessions. Adding this will reduce the number of queries to your main storage.
It implements CacheInterface
. Unlike the main storage, it does not have to be strongly consistent. However, the TTL should be strictly enforced.
An example implementation using Go maps is available.
type CacheInterface interface {
Get(key string) ([]byte, error)
Set(key string, value []byte, ttl time.Duration) error
Delete(key string) error
}
Rate limit storage
The rate limit storage implements RateLimitStorageInterface
. This storage is exclusively used for rate limiting. As with the main storage, your implementation should be strongly consistent.
The entry value, ID, counter, and optionally the expiration timestamp should be stored under each key. The ID and counter is used to prevent race conditions. The expiration is just a hint. You may delete entries past its expiration.
Consider using a fast, in-memory storage here.
An example implementation using Go maps is available.
type RateLimitStorageInterface interface {
Get(key string) ([]byte, string, int32, error)
Add(key string, value []byte, entryId string, expiresAt time.Time) error
Update(key string, value []byte, expiresAt time.Time, entryId string, counter int32) error
Delete(key string, entryId string, counter int32) error
}
User store
Create a user store that implements an UserStoreInterface
. This allows Faroe to interact with your existing user data.
type UserStoreInterface interface {
CreateUser(emailAddress string, passwordHash []byte, passwordHashAlgorithmId string, passwordSalt []byte) (UserStruct, error)
GetUser(userId string) (UserStruct, error)
GetUserByEmailAddress(emailAddress string) (UserStruct, error)
UpdateUserEmailAddress(userId string, emailAddress string, userEmailAddressCounter int32) error
UpdateUserPasswordHash(userId string, passwordHash []byte, passwordHashAlgorithmId string, passwordSalt []byte, userPasswordHashCounter int32) error
IncrementUserSessionsCounter(userId string, userSessionsCounter int32) error
DeleteUser(userId string) error
}
Example implementations are available:
Alternatively, you may create a separate user server to handle database operations for your users in a dedicated server. To connect to your user server, use NewUserServerClient()
to create UserServerStruct
, which implements UserStoreInterface
. NewUserServerClient()
takes an ActionInvocationEndpointClientInterface
, which is used to send action invocation requests to the user server's action invocation endpoint.
userStore := faroe.NewUserServerClient(actionInvocationEndpointClient)
type ActionInvocationEndpointClientInterface interface {
SendActionInvocationEndpointRequest(body string) (string, error)
}
Because Faroe doesn't prescribe the authentication method used for action invocation endpoints, this is where you implement whatever authentication mechanism your user action endpoint uses.
User password hash algorithm
The user password hash algorithm implements PasswordHashAlgorithmInterface
. The ID is a unique identifier for the algorithm. We recommend including the parameters alongside the algorithm name like argon2id.65536.3.1.32
.
We recommend using Argon2id with 64MiB of memory, 3 iterations, and 1 degree of parallelism. An example implementation for Argon2id is available.
type PasswordHashAlgorithmInterface interface {
Id() string
SaltSize() int
Hash(secret string, salt []byte) ([]byte, error)
}
User password reset temporary password hashing
The user password reset temporary password hash algorithm also implements PasswordHashAlgorithmInterface
.
Temporary passwords used in user password resets are generated by the server. Because they are much stronger than user-defined passwords, you may use a weaker hashing algorithm than for user passwords. That said, you should still treat them as passwords and use hashing algorithms intended for passwords. We recommend Argon2id with 16MiB of memory, 3 iterations, and 1 degree of parallelism.
Action error logger
The LogActionError()
method of ActionErrorLoggerInterface
will be used to log all internal errors.
type ActionErrorLoggerInterface interface {
LogActionError(timestamp time.Time, message string, actionInvocationId string, action string)
}
Email sender
The email sender is an implementation of EmailSenderInterface
.
type EmailSenderInterface interface {
SendSignupEmailAddressVerificationCode(emailAddress string, emailAddressVerificationCode string) error
SendUserEmailAddressUpdateEmailVerificationCode(emailAddress string, displayName string, emailAddressVerificationCode string) error
SendUserPasswordResetTemporaryPassword(emailAddress string, displayName string, temporaryPassword string) error
SendUserSignedInNotification(emailAddress string, displayName string, timestamp time.Time) error
SendUserPasswordUpdatedNotification(emailAddress string, displayName string, timestamp time.Time) error
SendUserEmailAddressUpdatedNotification(emailAddress string, displayName string, newEmailAddress string, timestamp time.Time) error
}
Storage namespace
Storage entry keys are not globally namespaced. For example, an entry in the rate limit storage can have a same key as an entry in the cache. If you use a shared storage, make sure to prefix the key with NAME.
(note the period) like main.
and rate_limit.
.
Create server
Create a new ServerStruct
with NewServer()
.
If you do not have a cache, pass EmptyCache
as the cache.
You can provide multiple user password hashing algorithms if your users use different algorithms. The first one will be used for hashing new passwords.
maxConcurrentPasswordHashingProcesses
is the number of maximum CPU threads you want to dedicate to hashing passwords (user passwords and temporary passwords). Password hashing is expensive and will block the thread for the duration of the process.
server := faroe.NewServer(
mainStorage,
cache,
rateLimitStorage,
userStore,
errorLogger,
[]faroe.PasswordHashAlgorithmInterface{userPasswordHashAlgorithm},
temporaryPasswordHashAlgorithm,
maxConcurrentPasswordHashingProcesses,
faroe.RealClock,
faroe.AllowAllEmailAddresses,
emailSender,
faroe.SessionConfigStruct{
InactivityTimeout: 30 * 24 * time.Hour,
ActivityCheckInterval: time.Minute,
CacheExpiration: time.Minute,
},
)
Handle action invocation endpoint requests with ServerStruct.ResolveActionInvocationEndpointRequestWithBlocklist()
. It takes an action invocation endpoint request body and returns the response body.
import (
"io"
"net/http"
"github.com/faroedev/faroe"
)
func handleRequest(w http.ResponseWriter, r *http.Request) {
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
resultJSON, err := server.ResolveActionInvocationEndpointRequestWithBlocklist(string(bodyBytes), nil)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(resultJSON))
return
}