Documentation ¶
Overview ¶
Package users implements common web user workflows. Most of the provided functions are regular net/http handler functions. The following functionality is provided:
- Signing up for a new user account
- Logging in and out
- Checking login status
- Resetting forgotten passwords
- Changing email and password
Special emphasis is placed on reducing the risk of someone hijacking user accounts. This is achieved by enforcing a certain user structure and following certain procedures:
- Users are identified by their email address.
- New or changed email addresses must be verified by clicking on a link emailed to that address.
- Users authenticate by entering their email address and a password.
- Password integrity is checked via ReasonablePassword() in the package github.com/rivo/sessions.
- Forgotten passwords are reset by clicking on a link emailed to the user.
If your application does not follow these principles, you may not be able to use this package as is. However, the code may serve as a starting point if you apply its principles to your own use case.
Note also the following:
- The package's functionality is based on the sessions package github.com/rivo/sessions.
- You need access to an SMTP email server to send verification and password reset emails.
- It uses Golang HTML templates for the various pages and text templates for the various emails sent to the user. These may all be customized to your needs.
- The template rendering functions used in this package are public and may prove useful for other parts of your application.
- The package is database agnostic. You may choose any storage system to store and retrieve user data.
- Internationalization is supported via the "lang" browser cookie.
Basic Example ¶
The users.Main() function registers all handlers and starts an HTTP server:
if err := users.Main(); err != nil { panic(err) }
Any other handlers can be added to the http.DefaultServeMux before calling users.Main(). Alternatively, you can start your own HTTP server. See the implementation of users.Main() for how to add the package's handlers.
Package Configuration ¶
See the package example for a most basic way to use the package. In addition, the global Config struct contains all the variables that need to be adjusted for your specific application. It provides sensible defaults out of the box which you can see in its documentation. The fields are as follows:
- ServerAddr: The address the HTTP server binds to. This is only needed if you start the server using the package's Main() function.
- Log: A logger for all major events of the package.
- LoggedIn: A function which is called any time a user was logged in successfully. This may be used for example to record the login time.
- NewUser: A function which returns a new object that implements the User interface.
- PasswordNames: A list of strings to be excluded from passwords. This is usually the application name or the domain name. Anything specific to the application. It will be passed to ReasonablePassword() in the package github.com/rivo/sessions.
- Route*: The fields starting with "Route" contain the routes for the various pages. They are used throughout the package's code as well as in the templates.
The following fields control how templates are handled:
- CacheTemplates: Whether or not templates are cached. If set to true, templates are only loaded the first time they are used and then stored for successive uses. This reduces the load on the local hard drive but any changes after the first use will not become visible.
- HTMLTemplateDir: The directory where the HTML templates are located.
- HTMLTemplateIncludes: Because Golang requires any referenced templates to be included while parsing, if you need to include more templates than the default "header.gohtml" and "footer.gohtml", they need to be specified here.
- MailTemplateDir: The directory where the email templates are located.
- MailTemplateIncludes: Same as HTMLTemplateIncludes but for email templates.
If your application supports internationalization, you can set the Internationalization field to true. If set to true, this package's code checks for the "lang" cookie and appends its value to the HTMLTemplateDir and MailTemplateDir directories to search for template files. Cookie values must be of the format "xx" or "xx-XX" (e.g. "en-US"). If they don't have this format or if the corresponding subdirectory does not exist, the search falls back to the HTMLTemplateDir and MailTemplateDir directories. It is up to the application to set the "lang" cookie.
Emails are sent if the SendEmails field is set to true. You can provide your own email function by implementing the SendEmail field. Alternatively, the net/smtp package is used to send emails. The following fields need to specified (fields starting with "SMTP" are only needed when you don't provide your own SendEmail implementation):
- SenderName: The name to be shown in the email's "From" field.
- SenderEmail: The sender's email address.
- SMTPHostname: The mail server's host address.
- SMTPPort: The mail server's port.
- SMTPUsername: The username to authenticate with the mail server.
- SMTPPassword: The password to authenticate with the mail server.
A number of functions serve as the interface to your database:
- SaveNewUserAtomic: Saves a new user to the database after making sure that no such user existed before.
- UpdateUser: Updates an existing user.
- LoadUserByVerificationID: Loads a user given a verification ID.
- LoadUserByPasswordToken: Loads a user given a password reset token.
- LoadUserByEmail: Loads a user given an email.
The User Object ¶
Anyone using this package must define a type which implements this package's User interface. A user is in one of three possible states:
- StateCreated: The user exists but has not yet been verified and can therefore not yet use the application.
- StateVerified: The user has been verified and has access to the application.
- StateExpired: The user account has expired. The application cannot be used anymore.
Users have an ID which must be unique (e.g. generated by CUID() in the package github.com/rivo/sessions). But this package may access users based on their unique email address, their verification ID, or their password reset token.
You must implement the Config.NewUser function.
Template Structure and Functions ¶
There are basic HTML templates (in the "html" subdirectory) and email templates (in the "mail" subdirectory). All HTML templates starting with "error_" are templates that will generate error messages which are then embedded in another HTML template. When starting to work with this package, you will want to make a copy of these two subdirectories and modify the templates to your needs.
This package implements some functions to render templates which are also public so you may use them in other places, too. The function RenderPage() takes a template filename and a data object (to which the template will be bound), renders the template, and sends it to the browser. Instead of calling this function, however, RenderPageBasic() is used more often. It calls RenderPage() but populates the data object with the Config object and the User object (if one was provided).
If an error message needs to be shown to the user, RenderPageError() can be used. This actually involves two templates, one to generate only the error message (these template files start with "error_"), and the other to generate the HTML file which shows the error message. Config and User will also be bound to the latter as well as any data sent to the error message template.
There is another function for errors, RenderProgramError(), which is used to show program errors. These are unexpected errors, for example database connection issues, and should always be followed up on. While the user usually only sees a basic error message, more detailed information about the error is sent to the logger for further inspection.
The SendMail() function renders mail templates (based on text/template) to send them to the user's email address.
When writing your own templates, it is helpful to make a copy of the existing example templates and modify them to your needs.
All templates include a header and a footer file. If you include more files, you will need to set the Config.HTMLTemplateIncludes and Config.MailTemplateIncludes fields accordingly.
Example ¶
package main import ( "fmt" "net/http" "time" "github.com/rivo/sessions" ) // ExampleUser implements the User interface. type ExampleUser struct { id string email string passwordHash []byte state int verificationID string vidCreated time.Time passwordToken string tokenCreated time.Time } func (u *ExampleUser) GetID() interface{} { return u.id } func (u *ExampleUser) SetID(id interface{}) { u.id = id.(string) } func (u *ExampleUser) SetState(state int) { u.state = state } func (u *ExampleUser) GetState() int { return u.state } func (u *ExampleUser) SetEmail(email string) { u.email = email } func (u *ExampleUser) GetEmail() string { return u.email } func (u *ExampleUser) SetPasswordHash(hash []byte) { u.passwordHash = hash } func (u *ExampleUser) GetPasswordHash() []byte { return u.passwordHash } func (u *ExampleUser) SetVerificationID(id string, created time.Time) { u.verificationID = id u.vidCreated = created } func (u *ExampleUser) GetVerificationID() (string, time.Time) { return u.verificationID, u.vidCreated } func (u *ExampleUser) SetPasswordToken(id string, created time.Time) { u.passwordToken = id u.tokenCreated = created } func (u *ExampleUser) GetPasswordToken() (string, time.Time) { return u.passwordToken, u.tokenCreated } func (u *ExampleUser) GetRoles() []string { return nil } func main() { // We need a way to create new users. Config.NewUser = func() User { return &ExampleUser{ id: sessions.CUID(), } } // Set a starting point for when users have just logged in. Config.RouteLoggedIn = "/start" // Add a handler for the start page. http.HandleFunc(Config.RouteLoggedIn, func(response http.ResponseWriter, request *http.Request) { // Is a user logged in?. if user, _, _ := IsLoggedIn(response, request); user != nil { if user == nil { fmt.Fprint(response, "<body>No user is logged in</body>") return } // Yes, a user is logged in. fmt.Fprintf(response, "<body>User %s (%s) is logged in", user.GetID(), user.GetEmail()) if user.GetState() == StateExpired { fmt.Fprint(response, ", but expired") } fmt.Fprintf(response, ` <form action="%s" method="POST"><button>Log out</button></form></body>`, Config.RouteLogOut) return } fmt.Fprint(response, "<body>No user is logged in</body>") }) // Start the server. if err := Main(); err != nil { Config.Log.Printf("Server execution failed: %s", err) } }
Output:
Index ¶
- Constants
- Variables
- func Change(response http.ResponseWriter, request *http.Request)
- func ForgottenPassword(response http.ResponseWriter, request *http.Request)
- func LogIn(response http.ResponseWriter, request *http.Request)
- func LogOut(response http.ResponseWriter, request *http.Request)
- func Main() error
- func RenderPage(response http.ResponseWriter, request *http.Request, htmlTemplate string, ...)
- func RenderPageBasic(response http.ResponseWriter, request *http.Request, htmlTemplate string, ...)
- func RenderPageError(response http.ResponseWriter, request *http.Request, ...)
- func RenderProgramError(response http.ResponseWriter, request *http.Request, ...)
- func ResetPassword(response http.ResponseWriter, request *http.Request)
- func SendMail(request *http.Request, email, mailTemplate string, data interface{}) error
- func SignUp(response http.ResponseWriter, request *http.Request)
- func Verify(response http.ResponseWriter, request *http.Request)
- type User
Examples ¶
Constants ¶
const ( StateCreated = iota // User account has been created but not yet verified. StateVerified // User account has been verified and can be used. StateExpired // User account has expired. User can log in but don't have access to functionality anymore. )
The states a user account is in at any given time.
Variables ¶
var Config = struct { // The address the HTTP server binds to. ServerAddr string // The logger to which messages produced in this package are sent. Log *log.Logger // A list of names to exclude from passwords. This is typically the name of // your application, its domain name etc. PasswordNames []string // Routes. RouteSignUp string // The signup page. RouteVerify string // The page where the user verifies their email address. RouteLogIn string // The page with the log-in form. RouteLoggedIn string // The page to which the user is redirected after logging in. RouteLogOut string // The logout page. RouteLoggedOut string // The page to which the user is redirected after logging out. RouteForgottenPassword string // The forgotten password page. RouteResetPassword string // The page where the user can choose a new password. RouteChange string // The page where the user can change their email address and/or password. // Template settings. CacheTemplates bool // If true, templates are cached after their first use. HTMLTemplateDir string HTMLTemplateIncludes []string // Any HTML templates which may be included by other templates. MailTemplateDir string MailTemplateIncludes []string // Any mail templates which may be included by other templates. // If this value is set to true, the functions in this package will read the // value of the user's "lang" cookie and, provided it is a valid language code // such as "en" or "en-US", will access the templates in the subdirectory // of HTMLTemplateDir and MailTemplateDir with the name of the language code. // If the value is false, such a code could not be determined, or the // directory does not exist, no subdirectories are used. Internationalization bool // Email related settings. SendEmails bool SendEmail func(recipient, subject, body string) error // If provided, the following email parameters are ignored. SenderName string SenderEmail string SMTPHostname string SMTPPort int SMTPUsername string SMTPPassword string // NewUser returns a new user object. For the purposes of this package, only // a user ID needs to be set. Other fields will be populated by this package. // // The default is a nil function so it must be implemented by users of this // package. NewUser func() User // SaveNewUserAtomic saves a new user to the database. If a user with the same // email address previously existed, that existing user is returned (and the // new user is not saved). If no such user previously existed, they are saved // and a nil interface is returned. // // Checking for the existence of a user and inserting them needs to be an // atomic transaction to avoid the duplication of users due to race // conditions. SaveNewUserAtomic func(user User) (User, error) // UpdateUser updates an existing user (identified by their user ID) in the // database. UpdateUser func(user User) error // LoadUserByVerificationID loads a user given a verification ID. If no user // was found, it's not an error, just nil is returned. LoadUserByVerificationID func(id string) (User, error) // LoadUserByPasswordToken loads a user given a password token. If no user // was found, it's not an error, just nil is returned. LoadUserByPasswordToken func(token string) (User, error) // LoadUserByEmail loads a user given their email address. If no user was // found, it's not an error, just nil is returned. LoadUserByEmail func(email string) (User, error) // LoggedIn is called when a user was successfully logged in from a browser // at the given IP address. LoggedIn func(user User, ipAddress string) // ThrottleVerification throttles verification attempts. The default // implementation simply pauses all verification requests by one second. ThrottleVerification func() // ThrottleLogin throttles login attempts. The default implementation pauses // each login request by the same user for one second. ThrottleLogin func(email string) }{ ServerAddr: ":5050", Log: log.New(os.Stdout, "", log.LstdFlags), PasswordNames: []string{"example.com", "ExampleCom", "Example"}, RouteSignUp: "/signup", RouteVerify: "/verify", RouteLogIn: "/login", RouteLoggedIn: "/", RouteLogOut: "/logout", RouteLoggedOut: "/login", RouteForgottenPassword: "/forgottenpassword", RouteResetPassword: "/resetpassword", RouteChange: "/changeinfos", CacheTemplates: false, HTMLTemplateDir: "src/github.com/rivo/sessions/users/html", HTMLTemplateIncludes: []string{"header.gohtml", "footer.gohtml"}, MailTemplateDir: "src/github.com/rivo/sessions/users/mail", MailTemplateIncludes: []string{"header.tmpl", "footer.tmpl"}, Internationalization: false, SendEmails: false, SendEmail: nil, SenderName: "Example.com Support", SenderEmail: "support@example.com", SMTPHostname: "mail.example.com", SMTPPort: 25, SMTPUsername: "support@example.com", SMTPPassword: "password", NewUser: nil, SaveNewUserAtomic: func(user User) (User, error) { usersMutex.Lock() defer usersMutex.Unlock() for _, existingUser := range users { if existingUser.GetEmail() == user.GetEmail() { return existingUser, nil } } users = append(users, user) return nil, nil }, UpdateUser: func(user User) error { usersMutex.Lock() defer usersMutex.Unlock() for index, existingUser := range users { if existingUser.GetID() == user.GetID() { users[index] = user return nil } } return nil }, LoadUserByVerificationID: func(id string) (User, error) { usersMutex.RLock() defer usersMutex.RUnlock() for _, user := range users { vid, _ := user.GetVerificationID() if vid == id { return user, nil } } return nil, nil }, LoadUserByPasswordToken: func(token string) (User, error) { usersMutex.RLock() defer usersMutex.RUnlock() for _, user := range users { t, _ := user.GetPasswordToken() if t == token { return user, nil } } return nil, nil }, LoadUserByEmail: func(email string) (User, error) { usersMutex.RLock() defer usersMutex.RUnlock() for _, user := range users { if user.GetEmail() == email { return user, nil } } return nil, nil }, LoggedIn: nil, ThrottleVerification: func() { pauseMutex.Lock() time.Sleep(time.Second) pauseMutex.Unlock() }, ThrottleLogin: func(email string) { userMutexesMutex.Lock() if len(userMutexes) >= 1000 { userMutexes = make(map[string]*sync.Mutex) } mutex, ok := userMutexes[email] if !ok { mutex = &sync.Mutex{} userMutexes[email] = mutex } userMutexesMutex.Unlock() mutex.Lock() time.Sleep(time.Second) mutex.Unlock() }, }
Config contains all the settings and helper functions needed to run this package's code without any modifications. You will need to change many of these default values to run the code in this package.
Functions ¶
func Change ¶
func Change(response http.ResponseWriter, request *http.Request)
Change returns, upon a GET request, the "changeinfos.gohtml" template which allows a user to change their email address and/or password. Upon a POST request, any values that have been modified by the user are changed and will undergo the same procedure as a signup (e.g. verification for email changes). The "infoschanged.gohtml" template will be used for confirmation.
Any of this only works if a user is currently logged in (checked with IsLoggedIn()).
If there are more user attributes that need to be changed than just email and password, it makes sense to make a copy of this function and extend it to your needs.
func ForgottenPassword ¶
func ForgottenPassword(response http.ResponseWriter, request *http.Request)
ForgottenPassword renders the "forgottenpassword.gohtml" template upon a GET request, unless a user is logged in (checked with IsLoggedIn()), in which case they are redirected to Config.RouteLoggedIn. Upon a POST request, an email is sent to the provided address. If the email address is of an existing user account, a temporary ID for a password reset link is generated and the link sent in the email (using the "reset_existing.tmpl" mail template). If the email address is unknown, the email sent will contain basic information about the request (using the "reset_unknown.tmpl" mail template). In any case, the "resetlinksent.gohtml" template is rendered.
func LogIn ¶
func LogIn(response http.ResponseWriter, request *http.Request)
LogIn logs a user into the system, i.e. attaches their User object to the current session. Upon a GET request, the "login.gohtml" template is shown if no user is logged in yet. If they are logged in (which is checked by calling IsLoggedIn()), they are redirected to Config.RouteLoggedIn. A POST request will cause a login attempt. After a successful login attempt, users are redirected to Config.RouteLoggedIn.
func LogOut ¶
func LogOut(response http.ResponseWriter, request *http.Request)
LogOut logs the user out of the current session. This does not work if it's a GET request (i.e. a simple link) because URL pre-fetching or proxies may cause users to log out. A simple form with a button will cause the logout link to be visited using POST:
<form action="/logout" method="POST"><button>Log out</button></form>
You can use CSS to make the button look like a link.
func Main ¶
func Main() error
Main makes your life simple by starting an HTTP server for you with the routes found in the Config variable. If you use this, for all remaining pages of your application, you only need to add your own handlers to the DefaultServerMux prior to calling this function. See package documentation for an example.
func RenderPage ¶
func RenderPage(response http.ResponseWriter, request *http.Request, htmlTemplate string, data interface{})
RenderPage renders the HTML template with the given name (located in Config.HTMLTemplateDir or a subdirectory of it, depending on the value of Config.Internationalization), attached to the given data, and sends it to the browser. It also instructs the browser not to cache this page. Other templates used by this htmlTemplate must be specified in Config.HTMLTemplateIncludes (with the exception of error and message templates which are included automatically).
func RenderPageBasic ¶
func RenderPageBasic(response http.ResponseWriter, request *http.Request, htmlTemplate string, user User)
RenderPageBasic calls RenderPage() on a map with a "config" key mapped to the Config object and, if the user is logged in, a "user" key mapped to the provided user (which can be nil if no user is logged in).
func RenderPageError ¶
func RenderPageError(response http.ResponseWriter, request *http.Request, htmlTemplate, errorName string, errorInfos interface{}, user User)
RenderPageError calls RenderPage() on a map with a "config" key mapped to the Config object, an "error" key mapped to the output of an error template (whose filename is constructed by prefixing errorName with "error_" and suffixing it with ".gohtml"), an "infos" key mapped to errorInfos, and - if a user was provided, meaning a user is logged in - a "user" key mapped to that user. The error template is only executed on errorInfos. The function also sends a Bad Request HTTP header.
func RenderProgramError ¶
func RenderProgramError(response http.ResponseWriter, request *http.Request, internalMessage, externalMessage string, err error)
RenderProgramError outputs a program error using the programerror.gohtml template and logs it along with a reference code. This should only be called on an unexpected error that we cannot recover from. An internal server error code is sent. If the external message is the empty string, the internal message is used.
func ResetPassword ¶
func ResetPassword(response http.ResponseWriter, request *http.Request)
ResetPassword checks, upon a GET request, the provided token and renders the "resetpassword.gohtml" template which contains a form to reset the user's password. Upon a POST request, the entered password is checked and saved. Upon success, the user is logged out of all sessions (provided sessions.Persistence.UserSessions is implemented) and the "passwordreset.gohtml" template is shown.
func SendMail ¶
SendMail sends an email based on the specified mail template (located in Config.MailTemplateDir or a subdirectory of it, depending on the value of Config.Internationalization) executed on the given data. The first line of the mail template will be used the email's subject. It must be followed by an empty line before the mail body starts.
If this call fails with "x509: certificate signed by unknown authority" and the email server (operated by you) is using a self-signed certificate, you will need to add it to your server's list of trusted certificates.
func SignUp ¶
func SignUp(response http.ResponseWriter, request *http.Request)
SignUp renders the "signup.gohtml" template upon a GET request, unless the user is already logged in (which is checked using IsLoggedIn()), in which case they are redirected to Config.RouteLoggedIn. On a POST request, it attempts to create a new user given an email address, a password, and its confirmation. A successful sign-up will lead to a validation email to be sent (using the "verification_new.tmpl" mail template for new users and the "verification_existing.tmpl" mail template for existing users) and the "validationsent.gohtml" template to be shown.
Types ¶
type User ¶
type User interface { sessions.User // We need to be able to copy user IDs. SetID(id interface{}) // At any time, a user is in exactly one state: StateCreated, StateVerified, // or StateExpired. SetState(state int) GetState() int // The users email address. This package will always change email addresses to // lowercase before setting or comparing them. SetEmail(email string) GetEmail() string // A hash of the user's password. This package uses golang.org/x/crypto/bcrypt // to generate and compare hashes. SetPasswordHash(hash []byte) GetPasswordHash() []byte // Verification IDs (a 22 character long string and its creation time) are // used to verify new (or changed) user accounts. SetVerificationID(id string, created time.Time) GetVerificationID() (string, time.Time) // Password tokens (a 22 character long string and its creation time) are // used to reset forgotten passwords. SetPasswordToken(id string, created time.Time) GetPasswordToken() (string, time.Time) }
User represents one user account. This is an extension of the sessions.User interface with additional getters and setters for fields used within this package.
See also Config.NewUser for a description on how to create a new user.
func IsLoggedIn ¶
func IsLoggedIn(response http.ResponseWriter, request *http.Request) (User, *sessions.Session, error)
IsLoggedIn checks if a user is logged in. If they are, the User object is returned. If they aren't logged in, nil is returned. The session object is also returned if there was one. (There is always one when a user is returned.)
If there was an error or if the user is not in a valid state, an (English) error message is returned which is clean enough to be shown to the user. Because errors are automatically logged and the returned user for an error is nil, it is often ok not to show the error to the user.
Callers will need to check for themselves if the user's state is StateExpired, in which case an according message should be displayed. In that state, users should not have access to any functionality but instead be presented with information instructing them what to do to regain access.
This function will also send HTTP headers that instruct the browser not to cache this page.