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