mirror of
https://github.com/volatiletech/authboss.git
synced 2024-11-28 08:58:38 +02:00
Add totp2fa module
This commit is contained in:
parent
9aed0c512d
commit
735cbb1ec5
@ -89,7 +89,7 @@ func (a *Authboss) UpdatePassword(ctx context.Context, user AuthableUser, newPas
|
||||
//
|
||||
// If allowHalfAuth is true then half-authed users are allowed through, otherwise a half-authed
|
||||
// user will not be allowed through.
|
||||
func Middleware(ab *Authboss, redirectToLogin bool, allowHalfAuth bool) func(http.Handler) http.Handler {
|
||||
func Middleware(ab *Authboss, redirectToLogin bool, allowHalfAuth bool, force2fa bool) 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)
|
||||
@ -116,7 +116,7 @@ func Middleware(ab *Authboss, redirectToLogin bool, allowHalfAuth bool) func(htt
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
if !allowHalfAuth && !IsFullyAuthed(r) {
|
||||
if !allowHalfAuth && !IsFullyAuthed(r) || !force2fa && !IsTwoFactored(r) {
|
||||
fail(w, r)
|
||||
return
|
||||
}
|
||||
|
@ -15,6 +15,8 @@ const (
|
||||
SessionHalfAuthKey = "halfauth"
|
||||
// SessionLastAction is the session key to retrieve the last action of a user.
|
||||
SessionLastAction = "last_action"
|
||||
// Session2FA is set when a user has been authenticated with a second factor
|
||||
Session2FA = "twofactor"
|
||||
// SessionOAuth2State is the xsrf protection key for oauth.
|
||||
SessionOAuth2State = "oauth2_state"
|
||||
// SessionOAuth2Params is the additional settings for oauth like redirection/remember.
|
||||
@ -221,13 +223,20 @@ func (c *ClientStateResponseWriter) putClientState() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFullyAuthed returns false if the user has a HalfAuth
|
||||
// IsFullyAuthed returns false if the user has a SessionHalfAuth
|
||||
// in his session.
|
||||
func IsFullyAuthed(r *http.Request) bool {
|
||||
_, hasHalfAuth := GetSession(r, SessionHalfAuthKey)
|
||||
return !hasHalfAuth
|
||||
}
|
||||
|
||||
// IsTwoFactored returns false if the user doesn't have a Session2FA
|
||||
// in his session.
|
||||
func IsTwoFactored(r *http.Request) bool {
|
||||
_, has2fa := GetSession(r, Session2FA)
|
||||
return has2fa
|
||||
}
|
||||
|
||||
// DelKnownSession deletes all known session variables, effectively
|
||||
// logging a user out.
|
||||
func DelKnownSession(w http.ResponseWriter) {
|
||||
|
@ -85,6 +85,10 @@ type Config struct {
|
||||
// OAuth2Providers lists all providers that can be used. See
|
||||
// OAuthProvider documentation for more details.
|
||||
OAuth2Providers map[string]OAuth2Provider
|
||||
|
||||
// TOTP2FAIssuer is the issuer that appears in the url when scanning a qr code
|
||||
// for google authenticator.
|
||||
TOTP2FAIssuer string
|
||||
}
|
||||
|
||||
Mail struct {
|
||||
|
@ -76,7 +76,7 @@ func (c *Confirm) PreventAuth(w http.ResponseWriter, r *http.Request, handled bo
|
||||
|
||||
cuser := authboss.MustBeConfirmable(user)
|
||||
if cuser.GetConfirmed() {
|
||||
logger.Infof("user %s was confirmed, allowing auth", user.GetPID())
|
||||
logger.Infof("user %s is confirmed, allowing auth", user.GetPID())
|
||||
return false, nil
|
||||
}
|
||||
|
||||
|
@ -19,6 +19,7 @@ const (
|
||||
|
||||
FormValueConfirm = "cnf"
|
||||
FormValueToken = "token"
|
||||
FormValueCode = "code"
|
||||
)
|
||||
|
||||
// UserValues from the login form
|
||||
@ -92,6 +93,16 @@ func (r RecoverEndValues) GetToken() string { return r.Token }
|
||||
// GetPassword for recovery
|
||||
func (r RecoverEndValues) GetPassword() string { return r.NewPassword }
|
||||
|
||||
// TwoFA for totp2fa_validate page
|
||||
type TwoFA struct {
|
||||
HTTPFormValidator
|
||||
|
||||
Code string
|
||||
}
|
||||
|
||||
// GetCode from authenticator
|
||||
func (r TwoFA) GetCode() string { return r.Code }
|
||||
|
||||
// HTTPBodyReader reads forms from various pages and decodes
|
||||
// them.
|
||||
type HTTPBodyReader struct {
|
||||
@ -233,6 +244,11 @@ func (h HTTPBodyReader) Read(page string, r *http.Request) (authboss.Validator,
|
||||
Token: values[FormValueToken],
|
||||
NewPassword: values[FormValuePassword],
|
||||
}, nil
|
||||
case "totp2fa_validate":
|
||||
return TwoFA{
|
||||
HTTPFormValidator: HTTPFormValidator{Values: values, Ruleset: rules, ConfirmFields: confirms},
|
||||
Code: values[FormValueCode],
|
||||
}, nil
|
||||
case "register":
|
||||
arbitrary := make(map[string]string)
|
||||
|
||||
|
459
otp/twofactor/totp2fa/totp.go
Normal file
459
otp/twofactor/totp2fa/totp.go
Normal file
@ -0,0 +1,459 @@
|
||||
// Package totp2fa implements two factor auth using time-based
|
||||
// one time passwords.
|
||||
package totp2fa
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/volatiletech/authboss"
|
||||
"github.com/volatiletech/authboss/otp/twofactor"
|
||||
)
|
||||
|
||||
const (
|
||||
otpKeyFormat = "otpauth://totp/%s:%s?issuer=%s&secret=%s"
|
||||
)
|
||||
|
||||
// Session keys
|
||||
const (
|
||||
SessionTOTPSecret = "totp_secret"
|
||||
SessionTOTPPendingPID = "totp_pending"
|
||||
)
|
||||
|
||||
// Pages
|
||||
const (
|
||||
PageTOTPValidate = "totp2fa_validate"
|
||||
PageTOTPValidateSuccess = "totp2fa_validate_success"
|
||||
)
|
||||
|
||||
// Data constants
|
||||
const (
|
||||
DataRecoveryCodes = "recovery_codes"
|
||||
DataValidateMode = "validate_mode"
|
||||
DataTOTPSecret = SessionTOTPSecret
|
||||
|
||||
dataValidate = "validate"
|
||||
dataValidateSetup = "setup"
|
||||
dataValidateConfirm = "confirm"
|
||||
dataValidateRemove = "remove"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoTOTPEnabled = errors.New("user does not have 2fa enabled")
|
||||
)
|
||||
|
||||
// User for TOTP
|
||||
type User interface {
|
||||
twofactor.User
|
||||
|
||||
GetTOTPSecretKey() string
|
||||
PutTOTPSecretKey(string)
|
||||
}
|
||||
|
||||
// TOTP implements time based one time passwords
|
||||
type TOTP struct {
|
||||
*authboss.Authboss
|
||||
}
|
||||
|
||||
// Setup the module
|
||||
func (t *TOTP) Setup() error {
|
||||
t.Authboss.Core.Router.Get("/2fa/setup/totp", t.Core.ErrorHandler.Wrap(t.GetSetup))
|
||||
t.Authboss.Core.Router.Post("/2fa/setup/totp", t.Core.ErrorHandler.Wrap(t.PostSetup))
|
||||
|
||||
t.Authboss.Core.Router.Get("/2fa/qr/totp", t.Core.ErrorHandler.Wrap(t.GetQRCode))
|
||||
|
||||
t.Authboss.Core.Router.Get("/2fa/confirm/totp", t.Core.ErrorHandler.Wrap(t.GetConfirm))
|
||||
t.Authboss.Core.Router.Post("/2fa/confirm/totp", t.Core.ErrorHandler.Wrap(t.PostConfirm))
|
||||
|
||||
t.Authboss.Core.Router.Get("/2fa/remove/totp", t.Core.ErrorHandler.Wrap(t.GetRemove))
|
||||
t.Authboss.Core.Router.Post("/2fa/remove/totp", t.Core.ErrorHandler.Wrap(t.PostRemove))
|
||||
|
||||
t.Authboss.Core.Router.Get("/2fa/validate/totp", t.Core.ErrorHandler.Wrap(t.GetValidate))
|
||||
t.Authboss.Core.Router.Post("/2fa/validate/totp", t.Core.ErrorHandler.Wrap(t.PostValidate))
|
||||
|
||||
t.Authboss.Events.Before(authboss.EventAuth, t.BeforeAuth)
|
||||
|
||||
return t.Authboss.Core.ViewRenderer.Load(PageTOTPValidate, PageTOTPValidateSuccess)
|
||||
}
|
||||
|
||||
// BeforeAuth 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) {
|
||||
if handled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
user := r.Context().Value(authboss.CTXKeyUser).(User)
|
||||
authboss.PutSession(w, SessionTOTPPendingPID, user.GetPID())
|
||||
|
||||
if len(user.GetTOTPSecretKey()) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var query string
|
||||
if len(r.URL.RawQuery) != 0 {
|
||||
query = "?" + r.URL.RawQuery
|
||||
}
|
||||
ro := authboss.RedirectOptions{
|
||||
Code: http.StatusTemporaryRedirect,
|
||||
RedirectPath: t.Paths.Mount + "/2fa/validate/totp" + query,
|
||||
}
|
||||
return true, t.Authboss.Config.Core.Redirector.Redirect(w, r, ro)
|
||||
}
|
||||
|
||||
// GetSetup shows a screen allows a user to opt in to setting up totp 2fa
|
||||
func (t *TOTP) GetSetup(w http.ResponseWriter, r *http.Request) error {
|
||||
data := authboss.HTMLData{DataValidateMode: dataValidateSetup}
|
||||
return t.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
|
||||
}
|
||||
|
||||
// PostSetup prepares adds a key to the user's session
|
||||
func (t *TOTP) PostSetup(w http.ResponseWriter, r *http.Request) error {
|
||||
abUser, err := t.CurrentUser(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := abUser.(User)
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: t.Authboss.Config.Modules.TOTP2FAIssuer,
|
||||
AccountName: user.GetEmail(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create a totp key")
|
||||
}
|
||||
|
||||
secret := key.Secret()
|
||||
authboss.PutSession(w, SessionTOTPSecret, secret)
|
||||
|
||||
ro := authboss.RedirectOptions{
|
||||
Code: http.StatusTemporaryRedirect,
|
||||
RedirectPath: t.Paths.Mount + "/2fa/confirm/totp",
|
||||
}
|
||||
return t.Core.Redirector.Redirect(w, r, ro)
|
||||
}
|
||||
|
||||
// GetQRCode responds with a QR code image
|
||||
func (t *TOTP) GetQRCode(w http.ResponseWriter, r *http.Request) error {
|
||||
abUser, err := t.CurrentUser(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := abUser.(User)
|
||||
|
||||
totpSecret, ok := authboss.GetSession(r, SessionTOTPSecret)
|
||||
|
||||
var key *otp.Key
|
||||
if !ok || len(totpSecret) == 0 {
|
||||
totpSecret = user.GetTOTPSecretKey()
|
||||
}
|
||||
|
||||
key, err = otp.NewKeyFromURL(
|
||||
fmt.Sprintf(otpKeyFormat,
|
||||
url.PathEscape(t.Authboss.Config.Modules.TOTP2FAIssuer),
|
||||
url.PathEscape(user.GetEmail()),
|
||||
url.QueryEscape(t.Authboss.Config.Modules.TOTP2FAIssuer),
|
||||
url.QueryEscape(totpSecret),
|
||||
))
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to reconstruct key from session key: %s")
|
||||
}
|
||||
|
||||
image, err := key.Image(200, 200)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create totp qr code")
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err = png.Encode(buf, image); err != nil {
|
||||
return errors.Wrap(err, "failed to encode qr code to png")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err = io.Copy(w, buf)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetConfirm requests a user to enter their totp code
|
||||
func (t *TOTP) GetConfirm(w http.ResponseWriter, r *http.Request) error {
|
||||
totpSecret, ok := authboss.GetSession(r, SessionTOTPSecret)
|
||||
if !ok {
|
||||
return errors.New("request failed, no totp secret present in session")
|
||||
}
|
||||
|
||||
data := authboss.HTMLData{
|
||||
DataValidateMode: dataValidateConfirm,
|
||||
DataTOTPSecret: totpSecret,
|
||||
}
|
||||
return t.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
|
||||
}
|
||||
|
||||
// PostConfirm finally activates totp if the code matches
|
||||
func (t *TOTP) PostConfirm(w http.ResponseWriter, r *http.Request) error {
|
||||
abUser, err := t.CurrentUser(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := abUser.(User)
|
||||
|
||||
totpSecret, ok := authboss.GetSession(r, SessionTOTPSecret)
|
||||
if !ok {
|
||||
return errors.New("request failed, no totp secret present in session")
|
||||
}
|
||||
|
||||
validator, err := t.Authboss.Config.Core.BodyReader.Read(PageTOTPValidate, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
totpCodeValues := MustHaveTOTPCodeValues(validator)
|
||||
inputCode := totpCodeValues.GetCode()
|
||||
|
||||
ok = totp.Validate(inputCode, totpSecret)
|
||||
if !ok {
|
||||
data := authboss.HTMLData{
|
||||
authboss.DataValidation: map[string][]string{"code": []string{"2fa code was invalid"}},
|
||||
DataValidateMode: dataValidateConfirm,
|
||||
DataTOTPSecret: totpSecret,
|
||||
}
|
||||
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
|
||||
}
|
||||
|
||||
codes, err := generateRecoveryCodes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
crypted, err := bcryptRecoveryCodes(codes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Save the user which activates 2fa
|
||||
user.PutTOTPSecretKey(totpSecret)
|
||||
user.PutRecoveryCodes(encodeRecoveryCodes(crypted))
|
||||
if err = t.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authboss.DelSession(w, SessionTOTPSecret)
|
||||
|
||||
logger := t.RequestLogger(r)
|
||||
logger.Infof("user %s enabled totp", user.GetPID())
|
||||
|
||||
data := authboss.HTMLData{
|
||||
DataRecoveryCodes: codes,
|
||||
DataValidateMode: dataValidateConfirm,
|
||||
}
|
||||
|
||||
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidateSuccess, data)
|
||||
}
|
||||
|
||||
// GetRemove starts removal
|
||||
func (t *TOTP) GetRemove(w http.ResponseWriter, r *http.Request) error {
|
||||
data := authboss.HTMLData{DataValidateMode: dataValidateRemove}
|
||||
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
|
||||
}
|
||||
|
||||
// PostRemove removes totp
|
||||
func (t *TOTP) PostRemove(w http.ResponseWriter, r *http.Request) error {
|
||||
user, ok, err := t.validate(r)
|
||||
switch {
|
||||
case err == errNoTOTPEnabled:
|
||||
data := authboss.HTMLData{
|
||||
authboss.DataErr: "totp 2fa not active",
|
||||
DataValidateMode: dataValidateRemove,
|
||||
}
|
||||
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
|
||||
case err != nil:
|
||||
return err
|
||||
case !ok:
|
||||
data := authboss.HTMLData{
|
||||
authboss.DataErr: "totp 2fa code incorrect",
|
||||
DataValidateMode: dataValidateRemove,
|
||||
}
|
||||
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
|
||||
}
|
||||
|
||||
user.PutTOTPSecretKey("")
|
||||
if err = t.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := t.RequestLogger(r)
|
||||
logger.Infof("user %s disabled totp", user.GetPID())
|
||||
|
||||
data := authboss.HTMLData{DataValidateMode: dataValidateRemove}
|
||||
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidateSuccess, data)
|
||||
}
|
||||
|
||||
// GetValidate shows a page to enter a code into
|
||||
func (t *TOTP) GetValidate(w http.ResponseWriter, r *http.Request) error {
|
||||
data := authboss.HTMLData{DataValidateMode: dataValidate}
|
||||
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
|
||||
}
|
||||
|
||||
// PostValidate redirects on success
|
||||
func (t *TOTP) PostValidate(w http.ResponseWriter, r *http.Request) error {
|
||||
logger := t.RequestLogger(r)
|
||||
|
||||
user, ok, err := t.validate(r)
|
||||
switch {
|
||||
case err == errNoTOTPEnabled:
|
||||
logger.Infof("user %s totp failure (not enabled)", user.GetPID())
|
||||
data := authboss.HTMLData{
|
||||
authboss.DataErr: "totp 2fa not active",
|
||||
DataValidateMode: dataValidate,
|
||||
}
|
||||
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
|
||||
case err != nil:
|
||||
return err
|
||||
case !ok:
|
||||
logger.Infof("user %s totp failure (wrong code)", user.GetPID())
|
||||
data := authboss.HTMLData{
|
||||
authboss.DataErr: "totp 2fa code incorrect",
|
||||
DataValidateMode: dataValidate,
|
||||
}
|
||||
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
|
||||
}
|
||||
|
||||
authboss.PutSession(w, authboss.SessionKey, user.GetPID())
|
||||
authboss.PutSession(w, authboss.Session2FA, "true")
|
||||
authboss.DelSession(w, authboss.SessionHalfAuthKey)
|
||||
authboss.DelSession(w, SessionTOTPPendingPID)
|
||||
authboss.DelSession(w, SessionTOTPSecret)
|
||||
|
||||
logger.Infof("user %s totp success", user.GetPID())
|
||||
|
||||
ro := authboss.RedirectOptions{
|
||||
Code: http.StatusTemporaryRedirect,
|
||||
Success: "successfully authenticated",
|
||||
RedirectPath: t.Authboss.Config.Paths.AuthLoginOK,
|
||||
FollowRedirParam: true,
|
||||
}
|
||||
return t.Authboss.Core.Redirector.Redirect(w, r, ro)
|
||||
}
|
||||
|
||||
func (t *TOTP) validate(r *http.Request) (User, bool, error) {
|
||||
var abUser authboss.User
|
||||
var err error
|
||||
|
||||
if pid, ok := authboss.GetSession(r, SessionTOTPPendingPID); ok && len(pid) != 0 {
|
||||
abUser, err = t.Authboss.Config.Storage.Server.Load(r.Context(), pid)
|
||||
} else {
|
||||
abUser, err = t.CurrentUser(r)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
user := abUser.(User)
|
||||
|
||||
secret := user.GetTOTPSecretKey()
|
||||
if len(secret) == 0 {
|
||||
return nil, false, errNoTOTPEnabled
|
||||
}
|
||||
|
||||
validator, err := t.Authboss.Config.Core.BodyReader.Read(PageTOTPValidate, r)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
totpCodeValues := MustHaveTOTPCodeValues(validator)
|
||||
input := totpCodeValues.GetCode()
|
||||
|
||||
return user, totp.Validate(input, secret), nil
|
||||
}
|
||||
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
const recoveryCodeLength = 10
|
||||
|
||||
// generateRecoveryCodes creates 10 recovery codes of the form:
|
||||
// abd34-1b24do
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func encodeRecoveryCodes(codes []string) string { return strings.Join(codes, ",") }
|
||||
func decodeRecoveryCodes(codes string) []string { return strings.Split(codes, ",") }
|
24
otp/twofactor/totp2fa/totp_payloads.go
Normal file
24
otp/twofactor/totp2fa/totp_payloads.go
Normal file
@ -0,0 +1,24 @@
|
||||
package totp2fa
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/volatiletech/authboss"
|
||||
)
|
||||
|
||||
// TOTPCodeValuer returns a code from the body
|
||||
type TOTPCodeValuer interface {
|
||||
authboss.Validator
|
||||
|
||||
GetCode() string
|
||||
}
|
||||
|
||||
// MustHaveTOTPCodeValues upgrades a validatable set of values
|
||||
// to ones specific to a user that needs to be recovered.
|
||||
func MustHaveTOTPCodeValues(v authboss.Validator) TOTPCodeValuer {
|
||||
if u, ok := v.(TOTPCodeValuer); ok {
|
||||
return u
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("bodyreader returned a type that could not be upgraded to TOTPCodeValuer: %T", v))
|
||||
}
|
114
otp/twofactor/totp2fa/totp_test.go
Normal file
114
otp/twofactor/totp2fa/totp_test.go
Normal file
@ -0,0 +1,114 @@
|
||||
package totp2fa
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateRecoveryCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
codes, err := generateRecoveryCodes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(codes) != 10 {
|
||||
t.Error("it should create 10 codes, got:", len(codes))
|
||||
}
|
||||
|
||||
rgx := regexp.MustCompile(`^[0-9a-z]{5}-[0-9a-z]{5}$`)
|
||||
for _, c := range codes {
|
||||
if !rgx.MatchString(c) {
|
||||
t.Errorf("code %s did not match regexp", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashRecoveryCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
codes, err := generateRecoveryCodes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(codes) != 10 {
|
||||
t.Error("it should create 10 codes, got:", len(codes))
|
||||
}
|
||||
|
||||
cryptedCodes, err := bcryptRecoveryCodes(codes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, c := range cryptedCodes {
|
||||
if !strings.HasPrefix(c, "$2a$10$") {
|
||||
t.Error("code did not look like bcrypt:", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseRecoveryCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
codes, err := generateRecoveryCodes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(codes) != 10 {
|
||||
t.Error("it should create 10 codes, got:", len(codes))
|
||||
}
|
||||
|
||||
cryptedCodes, err := bcryptRecoveryCodes(codes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, c := range cryptedCodes {
|
||||
if !strings.HasPrefix(c, "$2a$10$") {
|
||||
t.Error("code did not look like bcrypt:", c)
|
||||
}
|
||||
}
|
||||
|
||||
remaining, ok := useRecoveryCode(cryptedCodes, codes[4])
|
||||
if !ok {
|
||||
t.Error("should have used a code")
|
||||
}
|
||||
|
||||
if want, got := len(cryptedCodes)-1, len(remaining); want != got {
|
||||
t.Error("want:", want, "got:", got)
|
||||
}
|
||||
|
||||
if cryptedCodes[4] == remaining[4] {
|
||||
t.Error("it should have used number 4")
|
||||
}
|
||||
|
||||
remaining, ok = useRecoveryCode(remaining, codes[0])
|
||||
if !ok {
|
||||
t.Error("should have used a code")
|
||||
}
|
||||
|
||||
if want, got := len(cryptedCodes)-2, len(remaining); want != got {
|
||||
t.Error("want:", want, "got:", got)
|
||||
}
|
||||
|
||||
if cryptedCodes[0] == remaining[0] {
|
||||
t.Error("it should have used number 0")
|
||||
}
|
||||
|
||||
remaining, ok = useRecoveryCode(remaining, codes[len(codes)-1])
|
||||
if !ok {
|
||||
t.Error("should have used a code")
|
||||
}
|
||||
|
||||
if want, got := len(cryptedCodes)-3, len(remaining); want != got {
|
||||
t.Error("want:", want, "got:", got)
|
||||
}
|
||||
|
||||
if cryptedCodes[len(cryptedCodes)-1] == remaining[len(remaining)-1] {
|
||||
t.Error("it should have used number 0")
|
||||
}
|
||||
}
|
@ -1,20 +1,8 @@
|
||||
// Package otp allows authentication via one time passwords
|
||||
package otp
|
||||
// Package twofactor allows authentication via one time passwords
|
||||
package twofactor
|
||||
|
||||
import "github.com/volatiletech/authboss"
|
||||
|
||||
// Authenticator is a type that implements the basic functionality
|
||||
// to be able to authenticate via a one time password.
|
||||
type Authenticator interface {
|
||||
// Initialize by giving the user a way to enter the first otp
|
||||
Setup(User)
|
||||
// Verify the otp for the user
|
||||
Verify(User, string)
|
||||
// Remove otp for the user, requires the user be fully authenticated
|
||||
// and have authenticated with a one time password.
|
||||
Remove(User, string)
|
||||
}
|
||||
|
||||
// User interface
|
||||
type User interface {
|
||||
authboss.User
|
||||
@ -27,3 +15,113 @@ type User interface {
|
||||
// PutRecoveryCodes uses a single string to store many bcrypt'd recovery codes
|
||||
PutRecoveryCodes(codes string)
|
||||
}
|
||||
|
||||
// TOTPUser interface
|
||||
type TOTPUser interface {
|
||||
User
|
||||
|
||||
GetTOTPSecretKey() string
|
||||
PutTOTPSecretKey(string)
|
||||
}
|
||||
|
||||
// SMSUser interface
|
||||
type SMSUser interface {
|
||||
User
|
||||
|
||||
GetPhoneNumber() string
|
||||
PutPhoneNumber(string)
|
||||
}
|
||||
|
||||
// SMSPhoneGetter retrieves an initial phone number
|
||||
// to use as the SMS 2fa number.
|
||||
type SMSPhoneGetter interface {
|
||||
GetInitialPhoneNumber() string
|
||||
}
|
||||
|
||||
/*
|
||||
GET /2fa/setup/{sms,totp}
|
||||
POST /2fa/setup/{sms,totp}
|
||||
- sms:
|
||||
- send a 6-8 digit code to the users's phone number
|
||||
- save this temporary code in the session for the next API call
|
||||
|
||||
- totp:
|
||||
- generate a private key and store it, temporarily in session
|
||||
|
||||
GET /2fa/qr/{sms,totp}
|
||||
- totp:
|
||||
- send back an image of the secret key that's in the session, fallback to the database
|
||||
|
||||
GET /2fa/confirm/{sms,totp}
|
||||
POST /2fa/confirm/{sms,totp}
|
||||
- totp:
|
||||
- post the 2fa code, this finalizes the secret key in the session by storing it into the database
|
||||
- generate and save 10 recovery codes, return in data
|
||||
- sms:
|
||||
- post the sms code delivered to your phone, to finalize that sms phone number
|
||||
- generate and save 10 recovery codes, return in data
|
||||
|
||||
GET /2fa/remove/{sms,totp}
|
||||
- totp:
|
||||
- ask for a code
|
||||
- sms:
|
||||
- send code to fone
|
||||
|
||||
POST /2fa/remove/{sms,totp}
|
||||
- totp:
|
||||
- if code matches, remove 2fa
|
||||
- sms:
|
||||
- if code matches, remove 2fa
|
||||
|
||||
GET /2fa/recovery DOES NOT EXIST LOL, WAT 2 SHO?
|
||||
- show recovery codes
|
||||
POST /2fa/recovery/regenerate
|
||||
- regenerate 10 recovery codes and display them
|
||||
*/
|
||||
|
||||
/*
|
||||
|
||||
// Authenticator is a type that implements the basic functionality
|
||||
// to be able to authenticate via a one time password as a second factor.
|
||||
type Authenticator interface {
|
||||
// Setup a secret and generate a code from it so that the 2fa method can be attached
|
||||
// to the user if they correctly pass back the code. The secret itself
|
||||
// is stored in the session and will be passed back to be stored on the
|
||||
// user object in the enable step.
|
||||
Setup(User) (code string, secret string, err error)
|
||||
|
||||
// Enable 2fa on the user, requires the code produced
|
||||
// by the secret and the secret itself that will have come
|
||||
// from Setup.
|
||||
Enable(user User, code string, secret string) error
|
||||
|
||||
// Teardown prepares to disable 2fa on the user.
|
||||
Teardown(User) (code string, err error)
|
||||
|
||||
// Disable 2fa on the user, requires a code sent by teardown
|
||||
// or in some cases that the user will already know.
|
||||
Disable(user User, code string, secret string) error
|
||||
|
||||
// IsActive checks if this authenticator is active on the current user
|
||||
// This is to ensure that only one authentication method is active at a time
|
||||
IsActive(User) bool
|
||||
}
|
||||
|
||||
// Authenticator is the basic functionality for a second factor authenticator
|
||||
type Authenticator interface {
|
||||
Secret(User) (secret string, err error)
|
||||
Code(user User, secret string) (code string, err error)
|
||||
Verify(user User, code, secret string) error
|
||||
|
||||
Enabled(User) bool
|
||||
Enable(User) error
|
||||
Disable(User) error
|
||||
}
|
||||
|
||||
// QRAuthenticator is able to provide a QR code to represent it's secrets
|
||||
type QRAuthenticator interface {
|
||||
// Returns a file as []byte and a mime type
|
||||
QRCode(User) ([]byte, string)
|
||||
}
|
||||
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user