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

Merge branch 'dev'

This commit is contained in:
Aaron L 2018-12-16 22:54:11 -08:00
commit db25c5e30b
27 changed files with 1209 additions and 293 deletions

View File

@ -3,6 +3,38 @@
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).
## [2.2.0] - 2018-12-16
### Added
- Add e-mail confirmation before 2fa setup feature
- Add config value TwoFactorEmailAuthRequired
- Add a more flexible way of adding behaviors and requirements to
authboss.Middleware. This API is at authboss.Middleware2 temporarily
until we can make a breaking change.
### Fixed
- Fix a bug where GET /login would panic when no FormValueRedirect is
provided. (thanks @rarguelloF)
- Fix a bug where lowercase password requirements in the default rules
implementation were not being checked correctly (thanks @rarguelloF)
- Fix a bug in remember where a user would get half-authed even though they
were logged in depending on middleware ordering.
- Fix a bug where if you were using lock/remember modules with 2fa they
would fail since the events didn't contain the current user in the context
as the auth module delivers them.
- Fix a bug with 2fa where a locked account could get a double response
### Deprecated
- Deprecate the config field ConfirmMethod in favor of MailRouteMethod. See
documentation for these config fields to understand how to use them now.
- Deprecate Middleware/MountedMiddleware for Middleware2 and MountedMiddleware2
as these new APIs are more flexible. When v3 hits (Mounted)Middleware2 will
become just (Mounted)Middleware.
- Deprecate RoutesRedirectOnUnauthed in favor of ResponseOnUnauthed
## [2.1.1] - 2018-12-10
### Security

152
README.md
View File

@ -1,60 +1,5 @@
<img src="http://i.imgur.com/fPIgqLg.jpg"/>
<!-- TOC -->
- [Authboss](#authboss)
- [New to v2?](#new-to-v2)
- [Why use Authboss?](#why-use-authboss)
- [Getting Started](#getting-started)
- [App Requirements](#app-requirements)
- [CSRF Protection](#csrf-protection)
- [Request Throttling](#request-throttling)
- [Integration Requirements](#integration-requirements)
- [Middleware](#middleware)
- [Configuration](#configuration)
- [Storage and Core implementations](#storage-and-core-implementations)
- [ServerStorer implementation](#serverstorer-implementation)
- [User implementation](#user-implementation)
- [Values implementation](#values-implementation)
- [Config](#config)
- [Paths](#paths)
- [Modules](#modules)
- [Mail](#mail)
- [Storage](#storage)
- [Core](#core)
- [Available Modules](#available-modules)
- [Middlewares](#middlewares)
- [Use Cases](#use-cases)
- [Get Current User](#get-current-user)
- [Reset Password](#reset-password)
- [User Auth via Password](#user-auth-via-password)
- [User Auth via OAuth2](#user-auth-via-oauth2)
- [User Registration](#user-registration)
- [Confirming Registrations](#confirming-registrations)
- [Password Recovery](#password-recovery)
- [Remember Me](#remember-me)
- [Locking Users](#locking-users)
- [Expiring User Sessions](#expiring-user-sessions)
- [One Time Passwords](#one-time-passwords)
- [Two Factor Authentication](#two-factor-authentication)
- [Two-Factor Recovery](#two-factor-recovery)
- [Time-Based One Time Passwords 2FA (totp)](#time-based-one-time-passwords-2fa-totp)
- [Adding 2fa to a user](#adding-2fa-to-a-user)
- [Removing 2fa from a user](#removing-2fa-from-a-user)
- [Logging in with 2fa](#logging-in-with-2fa)
- [Using Recovery Codes](#using-recovery-codes)
- [Text Message 2FA (sms)](#text-message-2fa-sms)
- [Adding 2fa to a user](#adding-2fa-to-a-user-1)
- [Removing 2fa from a user](#removing-2fa-from-a-user-1)
- [Logging in with 2fa](#logging-in-with-2fa-1)
- [Using Recovery Codes](#using-recovery-codes-1)
- [Rendering Views](#rendering-views)
- [HTML Views](#html-views)
- [JSON Views](#json-views)
- [Data](#data)
<!-- /TOC -->
# Authboss
[![GoDoc](https://godoc.org/github.com/volatiletech/authboss?status.svg)](https://godoc.org/github.com/volatiletech/authboss)
@ -90,6 +35,64 @@ Here are a few bullet point reasons you might like to try it out:
* Saves you mistakes (at least using Authboss, people can bug fix as a collective and all benefit)
* Should integrate with or without any web framework
# Readme Table of Contents
<!-- TOC -->
- [Authboss](#authboss)
- [New to v2?](#new-to-v2)
- [Why use Authboss?](#why-use-authboss)
- [Readme Table of Contents](#readme-table-of-contents)
- [Getting Started](#getting-started)
- [App Requirements](#app-requirements)
- [CSRF Protection](#csrf-protection)
- [Request Throttling](#request-throttling)
- [Integration Requirements](#integration-requirements)
- [Middleware](#middleware)
- [Configuration](#configuration)
- [Storage and Core implementations](#storage-and-core-implementations)
- [ServerStorer implementation](#serverstorer-implementation)
- [User implementation](#user-implementation)
- [Values implementation](#values-implementation)
- [Config](#config)
- [Paths](#paths)
- [Modules](#modules)
- [Mail](#mail)
- [Storage](#storage)
- [Core](#core)
- [Available Modules](#available-modules)
- [Middlewares](#middlewares)
- [Use Cases](#use-cases)
- [Get Current User](#get-current-user)
- [Reset Password](#reset-password)
- [User Auth via Password](#user-auth-via-password)
- [User Auth via OAuth2](#user-auth-via-oauth2)
- [User Registration](#user-registration)
- [Confirming Registrations](#confirming-registrations)
- [Password Recovery](#password-recovery)
- [Remember Me](#remember-me)
- [Locking Users](#locking-users)
- [Expiring User Sessions](#expiring-user-sessions)
- [One Time Passwords](#one-time-passwords)
- [Two Factor Authentication](#two-factor-authentication)
- [Two-Factor Recovery](#two-factor-recovery)
- [Two-Factor Setup E-mail Authorization](#two-factor-setup-e-mail-authorization)
- [Time-Based One Time Passwords 2FA (totp)](#time-based-one-time-passwords-2fa-totp)
- [Adding 2fa to a user](#adding-2fa-to-a-user)
- [Removing 2fa from a user](#removing-2fa-from-a-user)
- [Logging in with 2fa](#logging-in-with-2fa)
- [Using Recovery Codes](#using-recovery-codes)
- [Text Message 2FA (sms)](#text-message-2fa-sms)
- [Adding 2fa to a user](#adding-2fa-to-a-user-1)
- [Removing 2fa from a user](#removing-2fa-from-a-user-1)
- [Logging in with 2fa](#logging-in-with-2fa-1)
- [Using Recovery Codes](#using-recovery-codes-1)
- [Rendering Views](#rendering-views)
- [HTML Views](#html-views)
- [JSON Views](#json-views)
- [Data](#data)
<!-- /TOC -->
# Getting Started
To get started with Authboss in the simplest way, is to simply create a Config, populate it
@ -181,7 +184,7 @@ to your app:
Everything under Config.Storage and Config.Core are required and you must provide them,
however you can optionally use default implementations from the
[defaults package](https://github.com/volatiletech/authboss/defaults).
[defaults package](https://github.com/volatiletech/authboss/tree/master/defaults).
This also provides an easy way to share implementations of certain stack pieces (like HTML Form Parsing).
As you saw in the example above these can be easily initialized with the `SetCore` method in that
package.
@ -290,7 +293,7 @@ Mail sending related options.
### Storage
These are the implementations of how storage on the server and the client are done in your
app. There are no default implementations for these at this time. See the Godoc for more information
app. There are no default implementations for these at this time. See the [Godoc](https://godoc.org/github.com/volatiletech/authboss) for more information
about what these are.
### Core
@ -462,7 +465,7 @@ This means the (whitelisted) values entered by the user previously will be acces
templates by using `.preserve.field_name`. Preserve may be empty or nil so use
`{{with ...}}` to make sure you don't have template errors.
There is additional Godoc documentation on the `RegisterPreserveFields` config option as well as
There is additional [Godoc documentation](https://godoc.org/github.com/volatiletech/authboss#Config) on the `RegisterPreserveFields` config option as well as
the `ArbitraryUser` and `ArbitraryValuer` interfaces themselves.
## Confirming Registrations
@ -659,6 +662,39 @@ Backup codes are one-time use, they are bcrypted for security, and they only all
authentication part, they cannot be used in lieu of a user's password, for that sort of recovery see
the `otp` module.
### Two-Factor Setup E-mail Authorization
| Info and Requirements | |
| --------------------- | -------- |
Module | twofactor
Pages | twofactor_verify
Routes | /2fa/recovery/regen
Emails | twofactor_verify_email_html, twofactor_verify_email_txt
Middlewares | [LoadClientStateMiddleware](https://godoc.org/github.com/volatiletech/authboss/#Authboss.LoadClientStateMiddleware)
ClientStorage | Session
ServerStorer | [ServerStorer](https://godoc.org/github.com/volatiletech/authboss/#ServerStorer)
User | [twofactor.User](https://godoc.org/github.com/volatiletech/authboss/otp/twofactor/#User)
Values | [twofactor.EmailVerifyTokenValuer](https://godoc.org/github.com/volatiletech/authboss/otp/twofactor/#EmailVerifyTokenValuer)
Mailer | Required
To enable this feature simply turn on
`authboss.Config.Modules.TwoFactorEmailAuthRequired` and new routes and
middlewares will be installed when you set up one of the 2fa modules.
When enabled, the routes for setting up 2fa on an account are protected by a
middleware that will redirect to `/2fa/{totp,sms}/email/verify` where
Page `twofactor_verify` is displayed. The user is prompted to authorize the
addition of 2fa to their account. The data for this page contains `email` and
a `url` for the POST. The url is required because this page is shared between
all 2fa types.
Once they POST to the url, a token is stored in their session and an e-mail is
sent with that token. When they click the link that goes to
`/2fa/{totp,sms}/email/verify/end` with a token in the query string the session
token is verified and exchanged for a value that says they're verified and
lastly it redirects them to the setup URL for the type of 2fa they were
attempting to setup.
### Time-Based One Time Passwords 2FA (totp)
| Info and Requirements | |

View File

@ -40,9 +40,9 @@ func (a *Auth) Init(ab *authboss.Authboss) (err error) {
// LoginGet simply displays the login form
func (a *Auth) LoginGet(w http.ResponseWriter, r *http.Request) error {
var data authboss.HTMLData
data := authboss.HTMLData{}
if redir := r.URL.Query().Get(authboss.FormValueRedirect); len(redir) != 0 {
data = authboss.HTMLData{authboss.FormValueRedirect: redir}
data[authboss.FormValueRedirect] = redir
}
return a.Core.Responder.Respond(w, r, http.StatusOK, PageLogin, data)
}
@ -100,6 +100,13 @@ func (a *Auth) LoginPost(w http.ResponseWriter, r *http.Request) error {
return nil
}
handled, err = a.Events.FireBefore(authboss.EventAuthHijack, w, r)
if err != nil {
return err
} else if handled {
return nil
}
logger.Infof("user %s logged in", pid)
authboss.PutSession(w, authboss.SessionKey, pid)
authboss.DelSession(w, authboss.SessionHalfAuthKey)

View File

@ -47,7 +47,7 @@ func TestAuthGet(t *testing.T) {
a := &Auth{ab}
r := mocks.Request("POST")
r := mocks.Request("GET")
r.URL.RawQuery = "redir=/redirectpage"
if err := a.LoginGet(nil, r); err != nil {
t.Error(err)

View File

@ -82,38 +82,96 @@ func (a *Authboss) UpdatePassword(ctx context.Context, user AuthableUser, newPas
return rmStorer.DelRememberTokens(ctx, user.GetPID())
}
// Middleware prevents someone from accessing a route that should be
// only allowed for users who are logged in.
// It allows the user through if they are logged in (SessionKey).
// MWRequirements are user requirements for authboss.Middleware
// in order to access the routes in protects. Requirements is a bit-set integer
// to be able to easily combine requirements like so:
//
// If redirectToLogin is true, the user will be redirected to the
// login page, otherwise they will get a 404.
// The redirect goes to: mountPath/login, this means it's expected that
// the auth module is loaded if this is set to true.
//
// If forceFullAuth is true then half-authed users (SessionHalfAuth)
// are not allowed through, otherwise a half-authed user will be allowed through.
//
// If force2fa is true, then users must have been logged in
// with 2fa (Session2FA) otherwise they will not be allowed through.
// authboss.RequireFullAuth | authboss.Require2FA
type MWRequirements int
// MWRespondOnFailure tells authboss.Middleware how to respond to
// a failure to meet the requirements.
type MWRespondOnFailure int
// Middleware requirements
const (
RequireNone MWRequirements = 0x00
// RequireFullAuth means half-authed users will also be rejected
RequireFullAuth MWRequirements = 0x01
// Require2FA means that users who have not authed with 2fa will
// be rejected.
Require2FA MWRequirements = 0x02
)
// Middleware response types
const (
// RespondNotFound does not allow users who are not logged in to know a
// route exists by responding with a 404.
RespondNotFound MWRespondOnFailure = iota
// RespondRedirect redirects users to the login page
RespondRedirect
// RespondUnauthorized provides a 401, this allows users to know the page
// exists unlike the 404 option.
RespondUnauthorized
)
// Middleware is deprecated. See Middleware2.
func Middleware(ab *Authboss, redirectToLogin bool, forceFullAuth bool, force2fa bool) func(http.Handler) http.Handler {
return MountedMiddleware(ab, false, redirectToLogin, forceFullAuth, force2fa)
}
// MountedMiddleware hides an option from typical users in "mountPathed".
// MountedMiddleware is deprecated. See MountedMiddleware2.
func MountedMiddleware(ab *Authboss, mountPathed, redirectToLogin, forceFullAuth, force2fa bool) func(http.Handler) http.Handler {
var reqs MWRequirements
failResponse := RespondNotFound
if forceFullAuth {
reqs |= RequireFullAuth
}
if force2fa {
reqs |= Require2FA
}
if redirectToLogin {
failResponse = RespondRedirect
}
return MountedMiddleware2(ab, mountPathed, reqs, failResponse)
}
// Middleware2 prevents someone from accessing a route that should be
// only allowed for users who are logged in.
// It allows the user through if they are logged in (SessionKey is present in
// the session).
//
// requirements are set by logical or'ing together requirements. eg:
//
// authboss.RequireFullAuth | authboss.Require2FA
//
// failureResponse is how the middleware rejects the users that don't meet
// the criteria. This should be chosen from the MWRespondOnFailure constants.
func Middleware2(ab *Authboss, requirements MWRequirements, failureResponse MWRespondOnFailure) func(http.Handler) http.Handler {
return MountedMiddleware2(ab, false, requirements, failureResponse)
}
// MountedMiddleware2 hides an option from typical users in "mountPathed".
// Normal routes should never need this only authboss routes (since they
// are behind mountPath typically). This method is exported only for use
// by Authboss modules, normal users should use Middleware instead.
//
// If mountPathed is true, then before redirecting to a URL it will add
// the mountpath to the front of it.
func MountedMiddleware(ab *Authboss, mountPathed, redirectToLogin, forceFullAuth, force2fa bool) func(http.Handler) http.Handler {
func MountedMiddleware2(ab *Authboss, mountPathed bool, reqs MWRequirements, failResponse MWRespondOnFailure) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log := ab.RequestLogger(r)
fail := func(w http.ResponseWriter, r *http.Request) {
if redirectToLogin {
switch failResponse {
case RespondNotFound:
log.Infof("not found for unauthorized user at: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
case RespondUnauthorized:
log.Infof("unauthorized for unauthorized user at: %s", r.URL.Path)
w.WriteHeader(http.StatusUnauthorized)
case RespondRedirect:
log.Infof("redirecting unauthorized user to login from: %s", r.URL.Path)
vals := make(url.Values)
@ -134,12 +192,9 @@ func MountedMiddleware(ab *Authboss, mountPathed, redirectToLogin, forceFullAuth
}
return
}
log.Infof("not found for unauthorized user at: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
if forceFullAuth && !IsFullyAuthed(r) || force2fa && !IsTwoFactored(r) {
if hasBit(reqs, RequireFullAuth) && !IsFullyAuthed(r) || hasBit(reqs, Require2FA) && !IsTwoFactored(r) {
fail(w, r)
return
}
@ -157,3 +212,7 @@ func MountedMiddleware(ab *Authboss, mountPathed, redirectToLogin, forceFullAuth
})
}
}
func hasBit(reqs, req MWRequirements) bool {
return reqs&req == req
}

View File

@ -5,8 +5,6 @@ import (
"net/http"
"net/http/httptest"
"testing"
"github.com/davecgh/go-spew/spew"
)
func TestAuthBossInit(t *testing.T) {
@ -137,8 +135,6 @@ func TestAuthbossMiddleware(t *testing.T) {
rec, called, hadUser := setupMore(false, false, false, false)
spew.Dump(ab.Storage)
if rec.Code != http.StatusNotFound {
t.Error("wrong code:", rec.Code)
}
@ -149,6 +145,40 @@ func TestAuthbossMiddleware(t *testing.T) {
t.Error("should not have had user")
}
})
t.Run("Reject401", func(t *testing.T) {
ab.Storage.SessionState = mockClientStateReadWriter{}
r := httptest.NewRequest("GET", "/super/secret", nil)
rec := httptest.NewRecorder()
w := ab.NewResponse(rec)
var err error
r, err = ab.LoadClientState(w, r)
if err != nil {
t.Fatal(err)
}
var mid func(http.Handler) http.Handler
mid = Middleware2(ab, RequireNone, RespondUnauthorized)
var called, hadUser bool
server := mid(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
hadUser = r.Context().Value(CTXKeyUser) != nil
w.WriteHeader(http.StatusOK)
}))
server.ServeHTTP(w, r)
if rec.Code != http.StatusUnauthorized {
t.Error("wrong code:", rec.Code)
}
if called {
t.Error("should not have been called")
}
if hadUser {
t.Error("should not have had user")
}
})
t.Run("RejectRedirect", func(t *testing.T) {
redir := &testRedirector{}
ab.Config.Core.Redirector = redir

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 ConfirmMethod 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,10 +129,16 @@ 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
// DEPRECATED: See ResponseOnUnauthed
// RoutesRedirectOnUnauthed controls whether or not a user is redirected
// or given a 404 when they are unauthenticated and attempting to access
// a route that's login-protected inside Authboss itself.
@ -116,6 +146,17 @@ type Config struct {
// their routes and this is the redirectToLogin parameter in that
// middleware that they pass through.
RoutesRedirectOnUnauthed bool
// ResponseOnUnauthed controls how a user is responded to when
// attempting to access a route that's login-protected inside Authboss
// itself. The otp/twofactor modules all use authboss.Middleware2 to
// protect their routes and this is the failResponse parameter in that
// middleware that they pass through.
//
// This deprecates RoutesRedirectOnUnauthed. If RoutesRedirectOnUnauthed
// is true, the value of this will be set to RespondRedirect until
// authboss v3.
ResponseOnUnauthed MWRespondOnFailure
}
Mail struct {
@ -201,6 +242,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 +251,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

@ -32,6 +32,8 @@ func (c contextKey) String() string {
}
// CurrentUserID retrieves the current user from the session.
// TODO(aarondl): This method never returns an error, one day we'll change
// the function signature.
func (a *Authboss) CurrentUserID(r *http.Request) (string, error) {
if pid := r.Context().Value(CTXKeyPID); pid != nil {
return pid.(string), nil

View File

@ -27,6 +27,10 @@ func (JSONRenderer) Load(names ...string) error {
// Render the data
func (j JSONRenderer) Render(ctx context.Context, page string, data authboss.HTMLData) (output []byte, contentType string, err error) {
if data == nil {
return []byte(`{"status":"success"}`), "application/json", nil
}
if _, hasStatus := data["status"]; !hasStatus {
failures := j.Failures
if len(failures) == 0 {

View File

@ -56,7 +56,7 @@ func (r Rules) Errors(toValidate string) authboss.ErrorList {
if upper < r.MinUpper {
errs = append(errs, FieldError{r.FieldName, errors.New(r.upperErr())})
}
if upper < r.MinLower {
if lower < r.MinLower {
errs = append(errs, FieldError{r.FieldName, errors.New(r.lowerErr())})
}
if numeric < r.MinNumeric {

View File

@ -48,6 +48,11 @@ func TestRules_Errors(t *testing.T) {
"hi",
"email: Must be between 3 and 5 characters",
},
{
Rules{FieldName: "email", MinUpper: 2, MinLower: 1},
"AA",
"email: Must contain at least 1 lowercase letter",
},
{
Rules{FieldName: "email", MinLetters: 5},
"13345",

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

@ -13,6 +13,13 @@ type Event int
const (
EventRegister Event = iota
EventAuth
// EventAuthHijack is used to steal the authentication process after a
// successful auth but before any session variable has been put in.
// Most useful for defining an additional step for authentication
// (like 2fa). It needs to be separate to EventAuth because other modules
// do checks that would also interrupt event handlers with an authentication
// failure so there's an ordering problem.
EventAuthHijack
EventOAuth2
EventAuthFail
EventOAuth2Fail

View File

@ -75,7 +75,13 @@ func (o *OTP) Init(ab *authboss.Authboss) (err error) {
o.Authboss.Config.Core.Router.Get("/otp/login", o.Authboss.Core.ErrorHandler.Wrap(o.LoginGet))
o.Authboss.Config.Core.Router.Post("/otp/login", o.Authboss.Core.ErrorHandler.Wrap(o.LoginPost))
middleware := authboss.MountedMiddleware(ab, true, ab.Config.Modules.RoutesRedirectOnUnauthed, false, false)
var unauthedResponse authboss.MWRespondOnFailure
if ab.Config.Modules.ResponseOnUnauthed != 0 {
unauthedResponse = ab.Config.Modules.ResponseOnUnauthed
} else if ab.Config.Modules.RoutesRedirectOnUnauthed {
unauthedResponse = authboss.RespondRedirect
}
middleware := authboss.MountedMiddleware2(ab, true, authboss.RequireNone, unauthedResponse)
o.Authboss.Config.Core.Router.Get("/otp/add", middleware(o.Authboss.Core.ErrorHandler.Wrap(o.AddGet)))
o.Authboss.Config.Core.Router.Post("/otp/add", middleware(o.Authboss.Core.ErrorHandler.Wrap(o.AddPost)))
@ -168,6 +174,13 @@ func (o *OTP) LoginPost(w http.ResponseWriter, r *http.Request) error {
return nil
}
handled, err = o.Events.FireBefore(authboss.EventAuthHijack, w, r)
if err != nil {
return err
} else if handled {
return nil
}
logger.Infof("user %s logged in via otp", pid)
authboss.PutSession(w, authboss.SessionKey, pid)
authboss.DelSession(w, authboss.SessionHalfAuthKey)

View File

@ -8,6 +8,7 @@ import (
"crypto/subtle"
"io"
"net/http"
"path"
"strconv"
"strings"
"time"
@ -97,23 +98,48 @@ 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)))
var unauthedResponse authboss.MWRespondOnFailure
if s.Config.Modules.ResponseOnUnauthed != 0 {
unauthedResponse = s.Config.Modules.ResponseOnUnauthed
} else if s.Config.Modules.RoutesRedirectOnUnauthed {
unauthedResponse = authboss.RespondRedirect
}
abmw := authboss.MountedMiddleware2(s.Authboss, true, authboss.RequireFullAuth, unauthedResponse)
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 {
setupPath := path.Join(s.Authboss.Paths.Mount, "/2fa/sms/setup")
emailVerify, err := twofactor.SetupEmailVerify(s.Authboss, "sms", setupPath)
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))
s.Authboss.Core.Router.Post("/2fa/sms/validate", s.Core.ErrorHandler.Wrap(validate.Post))
s.Authboss.Events.Before(authboss.EventAuth, s.BeforeAuth)
s.Authboss.Events.Before(authboss.EventAuthHijack, s.HijackAuth)
return s.Authboss.Core.ViewRenderer.Load(
PageSMSConfirm,
@ -125,9 +151,9 @@ func (s *SMS) Setup() error {
)
}
// BeforeAuth stores the user's pid in a special temporary session variable
// HijackAuth stores the user's pid in a special temporary session variable
// and redirects them to the validation endpoint.
func (s *SMS) BeforeAuth(w http.ResponseWriter, r *http.Request, handled bool) (bool, error) {
func (s *SMS) HijackAuth(w http.ResponseWriter, r *http.Request, handled bool) (bool, error) {
if handled {
return false, nil
}
@ -365,6 +391,7 @@ func (s *SMSValidator) validateCode(w http.ResponseWriter, r *http.Request, user
}
if !verified {
r = r.WithContext(context.WithValue(r.Context(), authboss.CTXKeyUser, user))
handled, err := s.Authboss.Events.FireAfter(authboss.EventAuthFail, w, r)
if err != nil {
return err
@ -429,6 +456,7 @@ func (s *SMSValidator) validateCode(w http.ResponseWriter, r *http.Request, user
logger.Infof("user %s sms 2fa success", user.GetPID())
r = r.WithContext(context.WithValue(r.Context(), authboss.CTXKeyUser, user))
handled, err := s.Authboss.Events.FireAfter(authboss.EventAuth, w, r)
if err != nil {
return err

View File

@ -110,13 +110,13 @@ func (h *testHarness) setSession(key, value string) {
h.session.ClientValues[key] = value
}
func TestBeforeAuth(t *testing.T) {
func TestHijackAuth(t *testing.T) {
t.Parallel()
t.Run("Handled", func(t *testing.T) {
harness := testSetup()
handled, err := harness.sms.BeforeAuth(nil, nil, true)
handled, err := harness.sms.HijackAuth(nil, nil, true)
if handled {
t.Error("should not be handled")
}
@ -135,7 +135,7 @@ func TestBeforeAuth(t *testing.T) {
harness.putUserInCtx(user, &r)
harness.loadClientState(w, &r)
handled, err := harness.sms.BeforeAuth(w, r, false)
handled, err := harness.sms.HijackAuth(w, r, false)
if handled {
t.Error("should not be handled")
}
@ -147,7 +147,7 @@ func TestBeforeAuth(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
harness := testSetup()
handled, err := harness.sms.BeforeAuth(nil, nil, true)
handled, err := harness.sms.HijackAuth(nil, nil, true)
if handled {
t.Error("should not be handled")
}
@ -162,7 +162,7 @@ func TestBeforeAuth(t *testing.T) {
harness.putUserInCtx(user, &r)
harness.loadClientState(w, &r)
handled, err = harness.sms.BeforeAuth(w, r, false)
handled, err = harness.sms.HijackAuth(w, r, false)
if !handled {
t.Error("should be handled")
}

View File

@ -4,11 +4,13 @@ package totp2fa
import (
"bytes"
"context"
"fmt"
"image/png"
"io"
"net/http"
"net/url"
"path"
"github.com/pkg/errors"
"github.com/pquerna/otp"
@ -66,22 +68,47 @@ 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)))
var unauthedResponse authboss.MWRespondOnFailure
if t.Config.Modules.ResponseOnUnauthed != 0 {
unauthedResponse = t.Config.Modules.ResponseOnUnauthed
} else if t.Config.Modules.RoutesRedirectOnUnauthed {
unauthedResponse = authboss.RespondRedirect
}
abmw := authboss.MountedMiddleware2(t.Authboss, true, authboss.RequireFullAuth, unauthedResponse)
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 {
setupPath := path.Join(t.Authboss.Paths.Mount, "/2fa/totp/setup")
emailVerify, err := twofactor.SetupEmailVerify(t.Authboss, "totp", setupPath)
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))
t.Authboss.Events.Before(authboss.EventAuth, t.BeforeAuth)
t.Authboss.Events.Before(authboss.EventAuthHijack, t.HijackAuth)
return t.Authboss.Core.ViewRenderer.Load(
PageTOTPSetup,
@ -93,9 +120,9 @@ func (t *TOTP) Setup() error {
)
}
// BeforeAuth stores the user's pid in a special temporary session variable
// HijackAuth stores the user's pid in a special temporary session variable
// and redirects them to the validation endpoint.
func (t *TOTP) BeforeAuth(w http.ResponseWriter, r *http.Request, handled bool) (bool, error) {
func (t *TOTP) HijackAuth(w http.ResponseWriter, r *http.Request, handled bool) (bool, error) {
if handled {
return false, nil
}
@ -318,6 +345,7 @@ func (t *TOTP) PostValidate(w http.ResponseWriter, r *http.Request) error {
case err != nil:
return err
case !ok:
r = r.WithContext(context.WithValue(r.Context(), authboss.CTXKeyUser, user))
handled, err := t.Authboss.Events.FireAfter(authboss.EventAuthFail, w, r)
if err != nil {
return err
@ -341,6 +369,7 @@ func (t *TOTP) PostValidate(w http.ResponseWriter, r *http.Request) error {
logger.Infof("user %s totp 2fa success", user.GetPID())
r = r.WithContext(context.WithValue(r.Context(), authboss.CTXKeyUser, user))
handled, err := t.Authboss.Events.FireAfter(authboss.EventAuth, w, r)
if err != nil {
return err

View File

@ -105,13 +105,13 @@ func (h *testHarness) setSession(key, value string) {
h.session.ClientValues[key] = value
}
func TestBeforeAuth(t *testing.T) {
func TestHijackAuth(t *testing.T) {
t.Parallel()
t.Run("Handled", func(t *testing.T) {
harness := testSetup()
handled, err := harness.totp.BeforeAuth(nil, nil, true)
handled, err := harness.totp.HijackAuth(nil, nil, true)
if handled {
t.Error("should not be handled")
}
@ -130,7 +130,7 @@ func TestBeforeAuth(t *testing.T) {
harness.putUserInCtx(user, &r)
harness.loadClientState(w, &r)
handled, err := harness.totp.BeforeAuth(w, r, false)
handled, err := harness.totp.HijackAuth(w, r, false)
if handled {
t.Error("should not be handled")
}
@ -142,7 +142,7 @@ func TestBeforeAuth(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
harness := testSetup()
handled, err := harness.totp.BeforeAuth(nil, nil, true)
handled, err := harness.totp.HijackAuth(nil, nil, true)
if handled {
t.Error("should not be handled")
}
@ -157,7 +157,7 @@ func TestBeforeAuth(t *testing.T) {
harness.putUserInCtx(user, &r)
harness.loadClientState(w, &r)
handled, err = harness.totp.BeforeAuth(w, r, false)
handled, err = harness.totp.HijackAuth(w, r, false)
if !handled {
t.Error("should be handled")
}

View File

@ -1,14 +1,38 @@
// 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"
DataVerifyEmail = "email"
DataVerifyURL = "url"
)
const (
alphabet = "abcdefghijkmnopqrstuvwxyz0123456789"
recoveryCodeLength = 10
verifyEmailTokenSize = 16
)
// User interface
@ -24,165 +48,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,163 @@
// 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 {
var unauthedResponse authboss.MWRespondOnFailure
if rc.Config.Modules.ResponseOnUnauthed != 0 {
unauthedResponse = rc.Config.Modules.ResponseOnUnauthed
} else if rc.Config.Modules.RoutesRedirectOnUnauthed {
unauthedResponse = authboss.RespondRedirect
}
middleware := authboss.MountedMiddleware2(rc.Authboss, true, authboss.RequireFullAuth, unauthedResponse)
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,244 @@
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,
}
var unauthedResponse authboss.MWRespondOnFailure
if ab.Config.Modules.ResponseOnUnauthed != 0 {
unauthedResponse = ab.Config.Modules.ResponseOnUnauthed
} else if ab.Config.Modules.RoutesRedirectOnUnauthed {
unauthedResponse = authboss.RespondRedirect
}
middleware := authboss.MountedMiddleware2(ab, true, authboss.RequireFullAuth, unauthedResponse)
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{
DataVerifyEmail: user.GetEmail(),
DataVerifyURL: path.Join(e.Authboss.Paths.Mount, "2fa", e.TwofactorKind, "email/verify"),
}
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")
}
})
}

View File

@ -69,7 +69,8 @@ func (r *Remember) RememberAfterAuth(w http.ResponseWriter, req *http.Request, h
func Middleware(ab *authboss.Authboss) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Context().Value(authboss.CTXKeyPID) == nil && r.Context().Value(authboss.CTXKeyUser) == nil {
// Safely can ignore error here
if id, _ := ab.CurrentUserID(r); len(id) == 0 {
if err := Authenticate(ab, w, &r); err != nil {
logger := ab.RequestLogger(r)
logger.Errorf("failed to authenticate user via remember me: %+v", err)

View File

@ -4,9 +4,9 @@ package authboss
import "strconv"
const _Event_name = "EventRegisterEventAuthEventOAuth2EventAuthFailEventOAuth2FailEventRecoverStartEventRecoverEndEventGetUserEventGetUserSessionEventPasswordReset"
const _Event_name = "EventRegisterEventAuthEventAuthHijackEventOAuth2EventAuthFailEventOAuth2FailEventRecoverStartEventRecoverEndEventGetUserEventGetUserSessionEventPasswordReset"
var _Event_index = [...]uint8{0, 13, 22, 33, 46, 61, 78, 93, 105, 124, 142}
var _Event_index = [...]uint8{0, 13, 22, 37, 48, 61, 76, 93, 108, 120, 139, 157}
func (i Event) String() string {
if i < 0 || i >= Event(len(_Event_index)-1) {