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:
commit
db25c5e30b
32
CHANGELOG.md
32
CHANGELOG.md
@ -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
152
README.md
@ -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 | |
|
||||
|
11
auth/auth.go
11
auth/auth.go
@ -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)
|
||||
|
@ -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)
|
||||
|
99
authboss.go
99
authboss.go
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
45
config.go
45
config.go
@ -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
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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",
|
||||
|
@ -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},
|
||||
|
@ -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
|
||||
|
15
otp/otp.go
15
otp/otp.go
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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, ",") }
|
||||
|
163
otp/twofactor/twofactor_recover.go
Normal file
163
otp/twofactor/twofactor_recover.go
Normal 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, ",") }
|
244
otp/twofactor/twofactor_verify.go
Normal file
244
otp/twofactor/twofactor_verify.go
Normal 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
|
||||
}
|
332
otp/twofactor/twofactor_verify_test.go
Normal file
332
otp/twofactor/twofactor_verify_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user