1
0
mirror of https://github.com/volatiletech/authboss.git synced 2025-02-13 13:58:38 +02:00

Add twofactor setup e-mail validation options

This commit is contained in:
Aaron L 2018-11-01 22:44:52 -07:00
parent 97b72a4816
commit 931ccfba1f
12 changed files with 870 additions and 187 deletions

View File

@ -3,6 +3,18 @@
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Add e-mail confirmation before 2fa setup feature
- Add config value TwoFactorEmailAuthRequired
### Deprecated
- Deprecate the config field ConfirmMethod in favor of MailRouteMethod. See
documentation for these config fields to understand how to use them now.
## [2.1.0] - 2018-10-28
### Added

View File

@ -18,6 +18,13 @@ const (
SessionLastAction = "last_action"
// Session2FA is set when a user has been authenticated with a second factor
Session2FA = "twofactor"
// Session2FAAuthToken is a random token set in the session to be verified
// by e-mail.
Session2FAAuthToken = "twofactor_auth_token"
// Session2FAAuthed is in the session (and set to "true") when the user
// has successfully verified the token sent via e-mail in the two factor
// e-mail authentication process.
Session2FAAuthed = "twofactor_authed"
// SessionOAuth2State is the xsrf protection key for oauth.
SessionOAuth2State = "oauth2_state"
// SessionOAuth2Params is the additional settings for oauth

View File

@ -40,7 +40,8 @@ type Config struct {
// an unsuccessful oauth2 login
OAuth2LoginNotOK string
// RecoverOK is the redirect path after a successful recovery of a password.
// RecoverOK is the redirect path after a successful recovery of a
// password.
RecoverOK string
// RegisterOK is the redirect path after a successful registration.
@ -50,12 +51,20 @@ type Config struct {
// (eg https://www.happiness.com:8080) for url generation.
// No trailing slash.
RootURL string
// TwoFactorEmailAuthNotOK is where a user is redirected when
// the user attempts to add 2fa to their account without verifying
// their e-mail OR when they've completed the first step towards
// verification and need to check their e-mail to proceed.
TwoFactorEmailAuthNotOK string
}
Modules struct {
// BCryptCost is the cost of the bcrypt password hashing function.
BCryptCost int
// ConfirmMethod IS DEPRECATED! See MailRouteMethod instead.
//
// ConfirmMethod controls which http method confirm expects.
// This is because typically this is a GET request since it's a link
// from an e-mail, but in api-like cases it needs to be able to be a
@ -77,6 +86,21 @@ type Config struct {
// (default should be DELETE)
LogoutMethod string
// MailRouteMethod is used to set the type of request that's used for
// routes that require a token from an e-mail link's query string.
// This is things like confirm and two factor e-mail auth.
//
// You should probably set this to POST if you are building an API
// so that the user goes to the frontend with their link & token
// and the front-end calls the API with the token in a POST JSON body.
//
// This configuration setting deprecates ConfirmMethod.
// If ConfirmMethod is set to the default value (GET) then
// MailRouteMethod is used. If ConfirMethod is not the default value
// then it is used until Authboss v3 when only MailRouteMethod will be
// used.
MailRouteMethod string
// RegisterPreserveFields are fields used with registration that are
// to be rendered when post fails in a normal way
// (for example validation errors), they will be passed back in the
@ -105,6 +129,11 @@ type Config struct {
// OAuthProvider documentation for more details.
OAuth2Providers map[string]OAuth2Provider
// TwoFactorEmailAuthRequired forces users to first confirm they have
// access to their e-mail with the current device by clicking a link
// and confirming a token stored in the session.
TwoFactorEmailAuthRequired bool
// TOTP2FAIssuer is the issuer that appears in the url when scanning
// a qr code for google authenticator.
TOTP2FAIssuer string
@ -201,6 +230,7 @@ func (c *Config) Defaults() {
c.Paths.RecoverOK = "/"
c.Paths.RegisterOK = "/"
c.Paths.RootURL = "http://localhost:8080"
c.Paths.TwoFactorEmailAuthNotOK = "/"
c.Modules.BCryptCost = bcrypt.DefaultCost
c.Modules.ConfirmMethod = http.MethodGet
@ -209,6 +239,7 @@ func (c *Config) Defaults() {
c.Modules.LockWindow = 5 * time.Minute
c.Modules.LockDuration = 12 * time.Hour
c.Modules.LogoutMethod = "DELETE"
c.Modules.MailRouteMethod = http.MethodGet
c.Modules.RecoverLoginAfterRecovery = false
c.Modules.RecoverTokenDuration = 24 * time.Hour
}

View File

@ -56,7 +56,11 @@ func (c *Confirm) Init(ab *authboss.Authboss) (err error) {
}
var callbackMethod func(string, http.Handler)
switch c.Config.Modules.ConfirmMethod {
methodConfig := c.Config.Modules.ConfirmMethod
if methodConfig == http.MethodGet {
methodConfig = c.Config.Modules.MailRouteMethod
}
switch methodConfig {
case http.MethodGet:
callbackMethod = c.Authboss.Config.Core.Router.Get
case http.MethodPost:

View File

@ -190,6 +190,8 @@ func NewHTTPBodyReader(readJSON, useUsernameNotEmail bool) *HTTPBodyReader {
"confirm": {Rules{FieldName: FormValueConfirm, Required: true}},
"recover_start": {pidRules},
"recover_end": {passwordRule},
"twofactor_verify_end": {Rules{FieldName: FormValueToken, Required: true}},
},
Confirms: map[string][]string{
"register": {FormValuePassword, authboss.ConfirmPrefix + FormValuePassword},
@ -268,6 +270,12 @@ func (h HTTPBodyReader) Read(page string, r *http.Request) (authboss.Validator,
Token: values[FormValueToken],
NewPassword: values[FormValuePassword],
}, nil
case "twofactor_verify_end":
// Reuse ConfirmValues here, it's the same values we need
return ConfirmValues{
HTTPFormValidator: HTTPFormValidator{Values: values, Ruleset: rules, ConfirmFields: confirms},
Token: values[FormValueToken],
}, nil
case "totp2fa_confirm", "totp2fa_remove", "totp2fa_validate":
return TwoFA{
HTTPFormValidator: HTTPFormValidator{Values: values, Ruleset: rules, ConfirmFields: confirms},

View File

@ -97,17 +97,35 @@ func (s *SMS) Setup() error {
return errors.New("must have SMS.Sender set")
}
middleware := authboss.MountedMiddleware(s.Authboss, true, s.Authboss.Config.Modules.RoutesRedirectOnUnauthed, false, false)
s.Authboss.Core.Router.Get("/2fa/sms/setup", middleware(s.Core.ErrorHandler.Wrap(s.GetSetup)))
s.Authboss.Core.Router.Post("/2fa/sms/setup", middleware(s.Core.ErrorHandler.Wrap(s.PostSetup)))
abmw := authboss.MountedMiddleware(s.Authboss, true, s.Authboss.Config.Modules.RoutesRedirectOnUnauthed, false, false)
var middleware, verified func(func(w http.ResponseWriter, r *http.Request) error) http.Handler
middleware = func(handler func(http.ResponseWriter, *http.Request) error) http.Handler {
return abmw(s.Core.ErrorHandler.Wrap(handler))
}
if s.Authboss.Config.Modules.TwoFactorEmailAuthRequired {
emailVerify, err := twofactor.SetupEmailVerify(s.Authboss, "totp", "/2fa/totp/setup")
if err != nil {
return err
}
verified = func(handler func(http.ResponseWriter, *http.Request) error) http.Handler {
return abmw(emailVerify.Wrap(s.Core.ErrorHandler.Wrap(handler)))
}
} else {
verified = middleware
}
s.Authboss.Core.Router.Get("/2fa/sms/setup", verified(s.GetSetup))
s.Authboss.Core.Router.Post("/2fa/sms/setup", verified(s.PostSetup))
confirm := &SMSValidator{SMS: s, Page: PageSMSConfirm}
s.Authboss.Core.Router.Get("/2fa/sms/confirm", middleware(s.Core.ErrorHandler.Wrap(confirm.Get)))
s.Authboss.Core.Router.Post("/2fa/sms/confirm", middleware(s.Core.ErrorHandler.Wrap(confirm.Post)))
s.Authboss.Core.Router.Get("/2fa/sms/confirm", verified(confirm.Get))
s.Authboss.Core.Router.Post("/2fa/sms/confirm", verified(confirm.Post))
remove := &SMSValidator{SMS: s, Page: PageSMSRemove}
s.Authboss.Core.Router.Get("/2fa/sms/remove", middleware(s.Core.ErrorHandler.Wrap(remove.Get)))
s.Authboss.Core.Router.Post("/2fa/sms/remove", middleware(s.Core.ErrorHandler.Wrap(remove.Post)))
s.Authboss.Core.Router.Get("/2fa/sms/remove", middleware(remove.Get))
s.Authboss.Core.Router.Post("/2fa/sms/remove", middleware(remove.Post))
validate := &SMSValidator{SMS: s, Page: PageSMSValidate}
s.Authboss.Core.Router.Get("/2fa/sms/validate", s.Core.ErrorHandler.Wrap(validate.Get))

View File

@ -66,17 +66,35 @@ type TOTP struct {
// Setup the module
func (t *TOTP) Setup() error {
middleware := authboss.MountedMiddleware(t.Authboss, true, t.Authboss.Config.Modules.RoutesRedirectOnUnauthed, true, false)
t.Authboss.Core.Router.Get("/2fa/totp/setup", middleware(t.Core.ErrorHandler.Wrap(t.GetSetup)))
t.Authboss.Core.Router.Post("/2fa/totp/setup", middleware(t.Core.ErrorHandler.Wrap(t.PostSetup)))
abmw := authboss.MountedMiddleware(t.Authboss, true, t.Authboss.Config.Modules.RoutesRedirectOnUnauthed, true, false)
t.Authboss.Core.Router.Get("/2fa/totp/qr", middleware(t.Core.ErrorHandler.Wrap(t.GetQRCode)))
var middleware, verified func(func(w http.ResponseWriter, r *http.Request) error) http.Handler
middleware = func(handler func(http.ResponseWriter, *http.Request) error) http.Handler {
return abmw(t.Core.ErrorHandler.Wrap(handler))
}
t.Authboss.Core.Router.Get("/2fa/totp/confirm", middleware(t.Core.ErrorHandler.Wrap(t.GetConfirm)))
t.Authboss.Core.Router.Post("/2fa/totp/confirm", middleware(t.Core.ErrorHandler.Wrap(t.PostConfirm)))
if t.Authboss.Config.Modules.TwoFactorEmailAuthRequired {
emailVerify, err := twofactor.SetupEmailVerify(t.Authboss, "totp", "/2fa/totp/setup")
if err != nil {
return err
}
verified = func(handler func(http.ResponseWriter, *http.Request) error) http.Handler {
return abmw(emailVerify.Wrap(t.Core.ErrorHandler.Wrap(handler)))
}
} else {
verified = middleware
}
t.Authboss.Core.Router.Get("/2fa/totp/remove", middleware(t.Core.ErrorHandler.Wrap(t.GetRemove)))
t.Authboss.Core.Router.Post("/2fa/totp/remove", middleware(t.Core.ErrorHandler.Wrap(t.PostRemove)))
t.Authboss.Core.Router.Get("/2fa/totp/setup", verified(t.GetSetup))
t.Authboss.Core.Router.Post("/2fa/totp/setup", verified(t.PostSetup))
t.Authboss.Core.Router.Get("/2fa/totp/qr", verified(t.GetQRCode))
t.Authboss.Core.Router.Get("/2fa/totp/confirm", verified(t.GetConfirm))
t.Authboss.Core.Router.Post("/2fa/totp/confirm", verified(t.PostConfirm))
t.Authboss.Core.Router.Get("/2fa/totp/remove", middleware(t.GetRemove))
t.Authboss.Core.Router.Post("/2fa/totp/remove", middleware(t.PostRemove))
t.Authboss.Core.Router.Get("/2fa/totp/validate", t.Core.ErrorHandler.Wrap(t.GetValidate))
t.Authboss.Core.Router.Post("/2fa/totp/validate", t.Core.ErrorHandler.Wrap(t.PostValidate))

View File

@ -1,14 +1,37 @@
// Package twofactor allows authentication via one time passwords
package twofactor
import (
"crypto/rand"
"io"
"net/http"
"strings"
import "github.com/volatiletech/authboss"
"github.com/volatiletech/authboss"
"golang.org/x/crypto/bcrypt"
// Page constants
const (
PageRecovery2FA = "recovery2fa"
PageVerify2FA = "twofactor_verify"
PageVerifyEnd2FA = "twofactor_verify_end"
)
// Email constants
const (
EmailVerifyHTML = "twofactor_verify_email_html"
EmailVerifyTxt = "twofactor_verify_email_txt"
)
// Form value constants
const (
FormValueToken = "token"
)
// Data constants
const (
DataRecoveryCode = "recovery_code"
DataRecoveryCodes = "recovery_codes"
DataNumRecoveryCodes = "n_recovery_codes"
DataVerifyURL = "url"
)
const (
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
recoveryCodeLength = 10
verifyEmailTokenSize = 16
)
// User interface
@ -24,165 +47,3 @@ type User interface {
// bcrypt'd recovery codes
PutRecoveryCodes(codes string)
}
// Page constants
const (
PageRecovery2FA = "recovery2fa"
)
// Data constants
const (
DataRecoveryCode = "recovery_code"
DataRecoveryCodes = "recovery_codes"
DataNumRecoveryCodes = "n_recovery_codes"
)
const (
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
recoveryCodeLength = 10
)
// Recovery for two-factor authentication is handled by this type
type Recovery struct {
*authboss.Authboss
}
// Setup the module to provide recovery regeneration routes
func (rc *Recovery) Setup() error {
middleware := authboss.MountedMiddleware(rc.Authboss, true, rc.Authboss.Config.Modules.RoutesRedirectOnUnauthed, true, false)
rc.Authboss.Core.Router.Get("/2fa/recovery/regen", middleware(rc.Authboss.Core.ErrorHandler.Wrap(rc.GetRegen)))
rc.Authboss.Core.Router.Post("/2fa/recovery/regen", middleware(rc.Authboss.Core.ErrorHandler.Wrap(rc.PostRegen)))
return rc.Authboss.Core.ViewRenderer.Load(PageRecovery2FA)
}
// GetRegen shows a button that enables a user to regen their codes
// as well as how many codes are currently remaining.
func (rc *Recovery) GetRegen(w http.ResponseWriter, r *http.Request) error {
abUser, err := rc.CurrentUser(r)
if err != nil {
return err
}
user := abUser.(User)
var nCodes int
codes := user.GetRecoveryCodes()
if len(codes) != 0 {
nCodes++
}
for _, c := range codes {
if c == ',' {
nCodes++
}
}
data := authboss.HTMLData{DataNumRecoveryCodes: nCodes}
return rc.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageRecovery2FA, data)
}
// PostRegen regenerates the codes
func (rc *Recovery) PostRegen(w http.ResponseWriter, r *http.Request) error {
abUser, err := rc.CurrentUser(r)
if err != nil {
return err
}
user := abUser.(User)
codes, err := GenerateRecoveryCodes()
if err != nil {
return err
}
hashedCodes, err := BCryptRecoveryCodes(codes)
if err != nil {
return err
}
user.PutRecoveryCodes(EncodeRecoveryCodes(hashedCodes))
if err = rc.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil {
return err
}
data := authboss.HTMLData{DataRecoveryCodes: codes}
return rc.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageRecovery2FA, data)
}
// GenerateRecoveryCodes creates 10 recovery codes of the form:
// abd34-1b24do (using alphabet, of length recoveryCodeLength).
func GenerateRecoveryCodes() ([]string, error) {
byt := make([]byte, 10*recoveryCodeLength)
if _, err := io.ReadFull(rand.Reader, byt); err != nil {
return nil, err
}
codes := make([]string, 10)
for i := range codes {
builder := new(strings.Builder)
for j := 0; j < recoveryCodeLength; j++ {
if recoveryCodeLength/2 == j {
builder.WriteByte('-')
}
randNumber := byt[i*recoveryCodeLength+j] % byte(len(alphabet))
builder.WriteByte(alphabet[randNumber])
}
codes[i] = builder.String()
}
return codes, nil
}
// BCryptRecoveryCodes hashes each recovery code given and return them in a new
// slice.
func BCryptRecoveryCodes(codes []string) ([]string, error) {
cryptedCodes := make([]string, len(codes))
for i, c := range codes {
hash, err := bcrypt.GenerateFromPassword([]byte(c), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
cryptedCodes[i] = string(hash)
}
return cryptedCodes, nil
}
// UseRecoveryCode deletes the code that was used from the string slice and
// returns it, the bool is true if a code was used
func UseRecoveryCode(codes []string, inputCode string) ([]string, bool) {
input := []byte(inputCode)
use := -1
for i, c := range codes {
err := bcrypt.CompareHashAndPassword([]byte(c), input)
if err == nil {
use = i
break
}
}
if use < 0 {
return nil, false
}
ret := make([]string, len(codes)-1)
for j := range codes {
if j == use {
continue
}
set := j
if j > use {
set--
}
ret[set] = codes[j]
}
return ret, true
}
// EncodeRecoveryCodes is an alias for strings.Join(",")
func EncodeRecoveryCodes(codes []string) string { return strings.Join(codes, ",") }
// DecodeRecoveryCodes is an alias for strings.Split(",")
func DecodeRecoveryCodes(codes string) []string { return strings.Split(codes, ",") }

View File

@ -0,0 +1,157 @@
// Package twofactor allows authentication via one time passwords
package twofactor
import (
"crypto/rand"
"io"
"net/http"
"strings"
"github.com/volatiletech/authboss"
"golang.org/x/crypto/bcrypt"
)
// Recovery for two-factor authentication is handled by this type
type Recovery struct {
*authboss.Authboss
}
// Setup the module to provide recovery regeneration routes
func (rc *Recovery) Setup() error {
middleware := authboss.MountedMiddleware(rc.Authboss, true, rc.Authboss.Config.Modules.RoutesRedirectOnUnauthed, true, false)
rc.Authboss.Core.Router.Get("/2fa/recovery/regen", middleware(rc.Authboss.Core.ErrorHandler.Wrap(rc.GetRegen)))
rc.Authboss.Core.Router.Post("/2fa/recovery/regen", middleware(rc.Authboss.Core.ErrorHandler.Wrap(rc.PostRegen)))
return rc.Authboss.Core.ViewRenderer.Load(PageRecovery2FA)
}
// GetRegen shows a button that enables a user to regen their codes
// as well as how many codes are currently remaining.
func (rc *Recovery) GetRegen(w http.ResponseWriter, r *http.Request) error {
abUser, err := rc.CurrentUser(r)
if err != nil {
return err
}
user := abUser.(User)
var nCodes int
codes := user.GetRecoveryCodes()
if len(codes) != 0 {
nCodes++
}
for _, c := range codes {
if c == ',' {
nCodes++
}
}
data := authboss.HTMLData{DataNumRecoveryCodes: nCodes}
return rc.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageRecovery2FA, data)
}
// PostRegen regenerates the codes
func (rc *Recovery) PostRegen(w http.ResponseWriter, r *http.Request) error {
abUser, err := rc.CurrentUser(r)
if err != nil {
return err
}
user := abUser.(User)
codes, err := GenerateRecoveryCodes()
if err != nil {
return err
}
hashedCodes, err := BCryptRecoveryCodes(codes)
if err != nil {
return err
}
user.PutRecoveryCodes(EncodeRecoveryCodes(hashedCodes))
if err = rc.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil {
return err
}
data := authboss.HTMLData{DataRecoveryCodes: codes}
return rc.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageRecovery2FA, data)
}
// GenerateRecoveryCodes creates 10 recovery codes of the form:
// abd34-1b24do (using alphabet, of length recoveryCodeLength).
func GenerateRecoveryCodes() ([]string, error) {
byt := make([]byte, 10*recoveryCodeLength)
if _, err := io.ReadFull(rand.Reader, byt); err != nil {
return nil, err
}
codes := make([]string, 10)
for i := range codes {
builder := new(strings.Builder)
for j := 0; j < recoveryCodeLength; j++ {
if recoveryCodeLength/2 == j {
builder.WriteByte('-')
}
randNumber := byt[i*recoveryCodeLength+j] % byte(len(alphabet))
builder.WriteByte(alphabet[randNumber])
}
codes[i] = builder.String()
}
return codes, nil
}
// BCryptRecoveryCodes hashes each recovery code given and return them in a new
// slice.
func BCryptRecoveryCodes(codes []string) ([]string, error) {
cryptedCodes := make([]string, len(codes))
for i, c := range codes {
hash, err := bcrypt.GenerateFromPassword([]byte(c), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
cryptedCodes[i] = string(hash)
}
return cryptedCodes, nil
}
// UseRecoveryCode deletes the code that was used from the string slice and
// returns it, the bool is true if a code was used
func UseRecoveryCode(codes []string, inputCode string) ([]string, bool) {
input := []byte(inputCode)
use := -1
for i, c := range codes {
err := bcrypt.CompareHashAndPassword([]byte(c), input)
if err == nil {
use = i
break
}
}
if use < 0 {
return nil, false
}
ret := make([]string, len(codes)-1)
for j := range codes {
if j == use {
continue
}
set := j
if j > use {
set--
}
ret[set] = codes[j]
}
return ret, true
}
// EncodeRecoveryCodes is an alias for strings.Join(",")
func EncodeRecoveryCodes(codes []string) string { return strings.Join(codes, ",") }
// DecodeRecoveryCodes is an alias for strings.Split(",")
func DecodeRecoveryCodes(codes string) []string { return strings.Split(codes, ",") }

View File

@ -0,0 +1,235 @@
package twofactor
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"github.com/volatiletech/authboss"
)
// EmailVerify has a middleware function that prevents access to routes
// unless e-mail has been verified.
//
// It does this by first setting where the user is coming from and generating
// an e-mail with a random token. The token is stored in the session.
//
// When the user clicks the e-mail link with the token, the token is confirmed
// by this middleware and the user is forwarded to the e-mail auth redirect.
type EmailVerify struct {
*authboss.Authboss
TwofactorKind string
TwofactorSetupURL string
}
// SetupEmailVerify registers routes for a particular 2fa method
func SetupEmailVerify(ab *authboss.Authboss, twofactorKind, setupURL string) (EmailVerify, error) {
e := EmailVerify{
Authboss: ab,
TwofactorKind: twofactorKind,
TwofactorSetupURL: setupURL,
}
middleware := authboss.MountedMiddleware(ab, true, ab.Config.Modules.RoutesRedirectOnUnauthed, true, false)
e.Authboss.Core.Router.Get("/2fa/"+twofactorKind+"/email/verify", middleware(ab.Core.ErrorHandler.Wrap(e.GetStart)))
e.Authboss.Core.Router.Post("/2fa/"+twofactorKind+"/email/verify", middleware(ab.Core.ErrorHandler.Wrap(e.PostStart)))
var routerMethod func(string, http.Handler)
switch ab.Config.Modules.MailRouteMethod {
case http.MethodGet:
routerMethod = ab.Core.Router.Get
case http.MethodPost:
routerMethod = ab.Core.Router.Post
default:
return e, errors.New("MailRouteMethod must be set to something in the config")
}
routerMethod("/2fa/"+twofactorKind+"/email/verify/end", middleware(ab.Core.ErrorHandler.Wrap(e.End)))
if err := e.Authboss.Core.ViewRenderer.Load(PageVerify2FA); err != nil {
return e, err
}
return e, e.Authboss.Core.MailRenderer.Load(EmailVerifyHTML, EmailVerifyTxt)
}
// GetStart shows the e-mail address and asks you to confirm that you would
// like to proceed.
func (e EmailVerify) GetStart(w http.ResponseWriter, r *http.Request) error {
cu, err := e.Authboss.CurrentUser(r)
if err != nil {
return err
}
user := cu.(User)
data := authboss.HTMLData{"email": user.GetEmail()}
return e.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageVerify2FA, data)
}
// PostStart sends an e-mail and shoves the user's token into the session
func (e EmailVerify) PostStart(w http.ResponseWriter, r *http.Request) error {
cu, err := e.Authboss.CurrentUser(r)
if err != nil {
return err
}
user := cu.(User)
ctx := r.Context()
logger := e.Authboss.Logger(ctx)
token, err := GenerateToken()
if err != nil {
return err
}
authboss.PutSession(w, authboss.Session2FAAuthToken, token)
logger.Infof("generated new 2fa e-mail verify token for user: %s", user.GetPID())
goVerifyEmail(e, ctx, user.GetEmail(), token)
ro := authboss.RedirectOptions{
Code: http.StatusTemporaryRedirect,
RedirectPath: e.Authboss.Config.Paths.TwoFactorEmailAuthNotOK,
Success: "An e-mail has been sent to confirm 2FA activation.",
}
return e.Authboss.Config.Core.Redirector.Redirect(w, r, ro)
}
// This is here so it can be mocked out by a test
var goVerifyEmail = func(e EmailVerify, ctx context.Context, to, token string) {
go e.SendVerifyEmail(ctx, to, token)
}
// SendVerifyEmail to the user
func (e EmailVerify) SendVerifyEmail(ctx context.Context, to, token string) {
logger := e.Authboss.Logger(ctx)
mailURL := e.mailURL(token)
email := authboss.Email{
To: []string{to},
From: e.Config.Mail.From,
FromName: e.Config.Mail.FromName,
Subject: e.Config.Mail.SubjectPrefix + "Add 2FA to Account",
}
logger.Infof("sending add 2fa verification e-mail to: %s", to)
ro := authboss.EmailResponseOptions{
Data: authboss.NewHTMLData(DataVerifyURL, mailURL),
HTMLTemplate: EmailVerifyHTML,
TextTemplate: EmailVerifyTxt,
}
if err := e.Authboss.Email(ctx, email, ro); err != nil {
logger.Errorf("failed to send 2fa verification e-mail to %s: %+v", to, err)
}
}
func (e EmailVerify) mailURL(token string) string {
query := url.Values{FormValueToken: []string{token}}
if len(e.Config.Mail.RootURL) != 0 {
return fmt.Sprintf("%s?%s",
e.Config.Mail.RootURL+"/2fa/"+e.TwofactorKind+"/email/verify/end",
query.Encode())
}
p := path.Join(e.Config.Paths.Mount, "/2fa/"+e.TwofactorKind+"/email/verify/end")
return fmt.Sprintf("%s%s?%s", e.Config.Paths.RootURL, p, query.Encode())
}
// End confirms the token passed in by the user (by the link in the e-mail)
func (e EmailVerify) End(w http.ResponseWriter, r *http.Request) error {
values, err := e.Authboss.Core.BodyReader.Read(PageVerifyEnd2FA, r)
if err != nil {
return err
}
tokenValues := MustHaveEmailVerifyTokenValues(values)
wantToken := tokenValues.GetToken()
givenToken, _ := authboss.GetSession(r, authboss.Session2FAAuthToken)
if 1 != subtle.ConstantTimeCompare([]byte(wantToken), []byte(givenToken)) {
ro := authboss.RedirectOptions{
Code: http.StatusTemporaryRedirect,
Failure: "invalid 2fa e-mail verification token",
RedirectPath: e.Authboss.Config.Paths.TwoFactorEmailAuthNotOK,
}
return e.Authboss.Core.Redirector.Redirect(w, r, ro)
}
authboss.DelSession(w, authboss.Session2FAAuthToken)
authboss.PutSession(w, authboss.Session2FAAuthed, "true")
ro := authboss.RedirectOptions{
Code: http.StatusTemporaryRedirect,
RedirectPath: e.TwofactorSetupURL,
}
return e.Authboss.Core.Redirector.Redirect(w, r, ro)
}
// Wrap a route and stop it from being accessed unless the Session2FAAuthed
// session value is "true".
func (e EmailVerify) Wrap(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !e.Authboss.Config.Modules.TwoFactorEmailAuthRequired {
handler.ServeHTTP(w, r)
return
}
// If this value exists the user's already verified
authed, _ := authboss.GetSession(r, authboss.Session2FAAuthed)
if authed == "true" {
handler.ServeHTTP(w, r)
return
}
redirURL := path.Join(e.Authboss.Config.Paths.Mount, "2fa", e.TwofactorKind, "email/verify")
ro := authboss.RedirectOptions{
Code: http.StatusTemporaryRedirect,
Failure: "You must first authorize adding 2fa by e-mail.",
RedirectPath: redirURL,
}
if err := e.Authboss.Core.Redirector.Redirect(w, r, ro); err != nil {
logger := e.Authboss.RequestLogger(r)
logger.Errorf("failed to redirect client: %+v", err)
return
}
})
}
// EmailVerifyTokenValuer returns a token from the body
type EmailVerifyTokenValuer interface {
authboss.Validator
GetToken() string
}
// MustHaveEmailVerifyTokenValues upgrades a validatable set of values
// to ones specific to a user that needs to be recovered.
func MustHaveEmailVerifyTokenValues(v authboss.Validator) EmailVerifyTokenValuer {
if u, ok := v.(EmailVerifyTokenValuer); ok {
return u
}
panic(fmt.Sprintf("bodyreader returned a type that could not be upgraded to an EmailVerifyTokenValues: %T", v))
}
// GenerateToken used for authenticating e-mails for 2fa setup
func GenerateToken() (string, error) {
rawToken := make([]byte, verifyEmailTokenSize)
if _, err := io.ReadFull(rand.Reader, rawToken); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(rawToken), nil
}

View File

@ -0,0 +1,332 @@
package twofactor
import (
"context"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"github.com/volatiletech/authboss"
"github.com/volatiletech/authboss/internal/mocks"
)
func TestSetupEmailVerify(t *testing.T) {
t.Parallel()
router := &mocks.Router{}
renderer := &mocks.Renderer{}
mailRenderer := &mocks.Renderer{}
ab := &authboss.Authboss{}
ab.Config.Core.Router = router
ab.Config.Core.ViewRenderer = renderer
ab.Config.Core.MailRenderer = mailRenderer
ab.Config.Core.ErrorHandler = &mocks.ErrorHandler{}
ab.Config.Modules.MailRouteMethod = http.MethodGet
if _, err := SetupEmailVerify(ab, "totp", "/2fa/totp/setup"); err != nil {
t.Error(err)
}
if err := router.HasGets("/2fa/totp/email/verify", "/2fa/totp/email/verify/end"); err != nil {
t.Error(err)
}
if err := router.HasPosts("/2fa/totp/email/verify"); err != nil {
t.Error(err)
}
if err := renderer.HasLoadedViews(PageVerify2FA); err != nil {
t.Error(err)
}
if err := mailRenderer.HasLoadedViews(EmailVerifyHTML, EmailVerifyTxt); err != nil {
t.Error(err)
}
}
type testEmailVerifyHarness struct {
emailverify EmailVerify
ab *authboss.Authboss
bodyReader *mocks.BodyReader
mailer *mocks.Emailer
responder *mocks.Responder
renderer *mocks.Renderer
redirector *mocks.Redirector
session *mocks.ClientStateRW
storer *mocks.ServerStorer
}
func testEmailVerifySetup() *testEmailVerifyHarness {
harness := &testEmailVerifyHarness{}
harness.ab = authboss.New()
harness.bodyReader = &mocks.BodyReader{}
harness.mailer = &mocks.Emailer{}
harness.redirector = &mocks.Redirector{}
harness.renderer = &mocks.Renderer{}
harness.responder = &mocks.Responder{}
harness.session = mocks.NewClientRW()
harness.storer = mocks.NewServerStorer()
harness.ab.Config.Core.BodyReader = harness.bodyReader
harness.ab.Config.Core.Logger = mocks.Logger{}
harness.ab.Config.Core.Responder = harness.responder
harness.ab.Config.Core.Redirector = harness.redirector
harness.ab.Config.Core.Mailer = harness.mailer
harness.ab.Config.Core.MailRenderer = harness.renderer
harness.ab.Config.Storage.SessionState = harness.session
harness.ab.Config.Storage.Server = harness.storer
harness.ab.Config.Modules.TwoFactorEmailAuthRequired = true
harness.emailverify = EmailVerify{
Authboss: harness.ab,
TwofactorKind: "totp",
TwofactorSetupURL: "/2fa/totp/setup",
}
return harness
}
func (h *testEmailVerifyHarness) loadClientState(w http.ResponseWriter, r **http.Request) {
req, err := h.ab.LoadClientState(w, *r)
if err != nil {
panic(err)
}
*r = req
}
func (h *testEmailVerifyHarness) putUserInCtx(u *mocks.User, r **http.Request) {
req := (*r).WithContext(context.WithValue((*r).Context(), authboss.CTXKeyUser, u))
*r = req
}
func TestEmailVerifyGetStart(t *testing.T) {
t.Parallel()
h := testEmailVerifySetup()
rec := httptest.NewRecorder()
r := mocks.Request("GET")
w := h.ab.NewResponse(rec)
u := &mocks.User{Email: "test@test.com"}
h.putUserInCtx(u, &r)
h.loadClientState(w, &r)
if err := h.emailverify.GetStart(w, r); err != nil {
t.Fatal(err)
}
if got := h.responder.Data["email"]; got != "test@test.com" {
t.Error("email was wrong:", got)
}
if got := h.responder.Page; got != PageVerify2FA {
t.Error("page was wrong:", got)
}
}
func TestEmailVerifyPostStart(t *testing.T) {
// NOT t.Parallel()
h := testEmailVerifySetup()
save := goVerifyEmail
goVerifyEmail = func(e EmailVerify, ctx context.Context, to string, token string) {
e.SendVerifyEmail(ctx, to, token)
}
defer func() {
goVerifyEmail = save
}()
rec := httptest.NewRecorder()
r := mocks.Request("POST")
w := h.ab.NewResponse(rec)
u := &mocks.User{Email: "test@test.com"}
h.putUserInCtx(u, &r)
h.loadClientState(w, &r)
if err := h.emailverify.PostStart(w, r); err != nil {
t.Fatal(err)
}
ro := h.redirector.Options
if ro.Code != http.StatusTemporaryRedirect {
t.Error("code wrong:", ro.Code)
}
if ro.Success != "An e-mail has been sent to confirm 2FA activation." {
t.Error("message was wrong:", ro.Success)
}
mail := h.mailer.Email
if mail.To[0] != "test@test.com" {
t.Error("email was sent to wrong person:", mail.To)
}
if mail.Subject != "Add 2FA to Account" {
t.Error("subject wrong:", mail.Subject)
}
urlRgx := regexp.MustCompile(`^http://localhost:8080/auth/2fa/totp/email/verify/end\?token=[_a-zA-Z0-9=%]+$`)
data := h.renderer.Data
if !urlRgx.MatchString(data[DataVerifyURL].(string)) {
t.Error("url is wrong:", data[DataVerifyURL])
}
}
func TestEmailVerifyEnd(t *testing.T) {
t.Parallel()
h := testEmailVerifySetup()
rec := httptest.NewRecorder()
r := mocks.Request("POST")
w := h.ab.NewResponse(rec)
h.bodyReader.Return = mocks.Values{Token: "abc"}
h.session.ClientValues[authboss.Session2FAAuthToken] = "abc"
h.loadClientState(w, &r)
if err := h.emailverify.End(w, r); err != nil {
t.Error(err)
}
ro := h.redirector.Options
if ro.Code != http.StatusTemporaryRedirect {
t.Error("code wrong:", ro.Code)
}
if ro.RedirectPath != "/2fa/totp/setup" {
t.Error("redir path wrong:", ro.RedirectPath)
}
// Flush session state
w.WriteHeader(http.StatusOK)
if h.session.ClientValues[authboss.Session2FAAuthed] != "true" {
t.Error("authed value not set")
}
if h.session.ClientValues[authboss.Session2FAAuthToken] != "" {
t.Error("auth token not removed")
}
}
func TestEmailVerifyEndFail(t *testing.T) {
t.Parallel()
h := testEmailVerifySetup()
rec := httptest.NewRecorder()
r := mocks.Request("POST")
w := h.ab.NewResponse(rec)
h.bodyReader.Return = mocks.Values{Token: "abc"}
h.session.ClientValues[authboss.Session2FAAuthToken] = "notabc"
h.loadClientState(w, &r)
if err := h.emailverify.End(w, r); err != nil {
t.Error(err)
}
ro := h.redirector.Options
if ro.Code != http.StatusTemporaryRedirect {
t.Error("code wrong:", ro.Code)
}
if ro.RedirectPath != "/" {
t.Error("redir path wrong:", ro.RedirectPath)
}
if ro.Failure != "invalid 2fa e-mail verification token" {
t.Error("did not get correct failure")
}
if h.session.ClientValues[authboss.Session2FAAuthed] != "" {
t.Error("should not be authed")
}
}
func TestEmailVerifyWrap(t *testing.T) {
t.Parallel()
t.Run("NotRequired", func(t *testing.T) {
h := testEmailVerifySetup()
rec := httptest.NewRecorder()
r := mocks.Request("POST")
w := h.ab.NewResponse(rec)
h.ab.Config.Modules.TwoFactorEmailAuthRequired = false
called := false
server := h.emailverify.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
server.ServeHTTP(w, r)
if !called {
t.Error("should have called the handler")
}
})
t.Run("Success", func(t *testing.T) {
h := testEmailVerifySetup()
rec := httptest.NewRecorder()
r := mocks.Request("POST")
w := h.ab.NewResponse(rec)
h.session.ClientValues[authboss.Session2FAAuthed] = "true"
h.loadClientState(w, &r)
called := false
server := h.emailverify.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
server.ServeHTTP(w, r)
if !called {
t.Error("should have called the handler")
}
})
t.Run("Fail", func(t *testing.T) {
h := testEmailVerifySetup()
rec := httptest.NewRecorder()
r := mocks.Request("POST")
w := h.ab.NewResponse(rec)
called := false
server := h.emailverify.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
server.ServeHTTP(w, r)
if called {
t.Error("should not have called the handler")
}
ro := h.redirector.Options
if ro.Code != http.StatusTemporaryRedirect {
t.Error("code wrong:", ro.Code)
}
if ro.RedirectPath != "/auth/2fa/totp/email/verify" {
t.Error("redir path wrong:", ro.RedirectPath)
}
if ro.Failure != "You must first authorize adding 2fa by e-mail." {
t.Error("did not get correct failure")
}
})
}