Skip to content

Commit

Permalink
extra 2fa requirements (#13)
Browse files Browse the repository at this point in the history
Actions are proved by user's signature
Multiple delivery channels for same 2FA method are possible
Sync masterkey from 3rdparty to verify user's signature
If more then 3 2FA configured must is only 2
  • Loading branch information
ice-cronus authored Oct 21, 2024
1 parent e109b89 commit b9eaaa6
Show file tree
Hide file tree
Showing 21 changed files with 756 additions and 232 deletions.
19 changes: 11 additions & 8 deletions accounts/DDL.sql
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TIMESTAMP NOT NULL,
id TEXT NOT NULL,
username TEXT NOT NULL UNIQUE,
master_pubkey TEXT NOT NULL UNIQUE,
clients TEXT[] NOT NULL,
email TEXT[],
phone_number TEXT[],
totp_authenticator_secret TEXT[],
ion_connect_relays TEXT[],
active_2fa_email smallint,
active_2fa_phone_number smallint,
active_2fa_totp_authenticator smallint,
CONSTRAINT active_2fa_email_valid CHECK (active_2fa_email < cardinality(email)),
CONSTRAINT active_2fa_phone_valid CHECK (active_2fa_phone_number < cardinality(phone_number)),
CONSTRAINT active_2fa_totp_valid CHECK (users.active_2fa_totp_authenticator < cardinality(totp_authenticator_secret)),
active_2fa_email boolean[], -- bitmask
active_2fa_phone_number boolean[], -- bitmask
active_2fa_totp_authenticator boolean[], -- bitmask
CONSTRAINT active_2fa_email_valid CHECK (cardinality(active_2fa_email) = cardinality(email)),
CONSTRAINT active_2fa_phone_valid CHECK (cardinality(active_2fa_phone_number) = cardinality(phone_number)),
CONSTRAINT active_2fa_totp_valid CHECK (cardinality(users.active_2fa_totp_authenticator) = cardinality(totp_authenticator_secret)),
primary key(id)
);
ALTER TABLE users
ADD COLUMN IF NOT EXISTS master_pubkey TEXT NOT NULL DEFAULT id UNIQUE;

DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'twofa_option') THEN
Expand All @@ -32,10 +35,10 @@ CREATE TABLE IF NOT EXISTS twofa_codes (
option twofa_option NOT NULL,
deliver_to TEXT NOT NULL,
code TEXT NOT NULL,
primary key (user_id, option)
primary key (user_id, option, deliver_to)
);

CREATE INDEX IF NOT EXISTS twofa_codes_option_code ON twofa_codes (option, code);
CREATE INDEX IF NOT EXISTS twofa_codes_option_code ON twofa_codes (option, deliver_to, code);

CREATE TABLE IF NOT EXISTS global (
value TEXT NOT NULL,
Expand Down
1 change: 1 addition & 0 deletions accounts/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func New(ctx context.Context) Accounts {
cl.RegisterPostProxyCallback(registrationUrl, acc.upsertUsernameFromRegistration)
cl.RegisterPostProxyCallback(completeLoginUrl, acc.upsertUsernameFromLogin)
cl.RegisterPostProxyCallback(delegatedLoginUrl, acc.upsertUsernameFromLogin)
cl.RegisterPostProxyCallback(completeRegistrationUrl, acc.upsertWalletPubKeyFromRegistration)
acc.delegatedRPClient = cl
for _, opt := range AllTwoFAOptions {
acc.concurrentlyGeneratedCodes[opt] = &sync.Map{}
Expand Down
59 changes: 38 additions & 21 deletions accounts/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,22 @@ type (
Accounts interface {
io.Closer
ProxyDelegatedRelyingParty(ctx context.Context, rw http.ResponseWriter, r *http.Request)
Verify2FA(ctx context.Context, userID string, codes map[TwoFAOptionEnum]string) error
Delete2FA(ctx context.Context, userID string, codes map[TwoFAOptionEnum]string, twoFAToDel TwoFAOptionEnum, toDel string) error
Send2FA(ctx context.Context, userID string, channel TwoFAOptionEnum, deliverTo *string, language string, verificationUsingExisting2FA map[TwoFAOptionEnum]string) (authenticatorUri *string, err error)
StartDelegatedRecovery(ctx context.Context, username, credentialID string, codes map[TwoFAOptionEnum]string) (resp *StartedDelegatedRecovery, err error)
Verify2FA(ctx context.Context, userID string, codes map[TwoFAOptionWithAddr]string) error
Delete2FA(ctx context.Context, userID string, codes map[TwoFAOptionWithAddr]string, twoFAToDel TwoFAOptionEnum, toDel string) error
Send2FA(ctx context.Context, userID string, channel TwoFAOptionEnum, deliverTo *string, language string, verificationUsingExisting2FA map[TwoFAOptionWithAddr]string) (authenticatorUri *string, err error)
StartDelegatedRecovery(ctx context.Context, username, credentialID string, codes map[TwoFAOptionWithAddr]string) (resp *StartedDelegatedRecovery, err error)
GetOrAssignIONConnectRelays(ctx context.Context, userID string, followees []string) (relays []string, err error)
GetIONConnectIndexerRelays(ctx context.Context, userID string) (indexers []string, err error)
GetUser(ctx context.Context, userID string) (usr *User, err error)
HealthCheck(ctx context.Context) error
}

TwoFAOptionEnum = string
TwoFAOptionEnum string
TwoFAOptionWithAddr struct {
opt TwoFAOptionEnum
idx int
addr string
} // email:[email protected], for the maps to separate codes for same channel
StartedDelegatedRecovery = dfns.StartedDelegatedRecovery
DelegatedRelyingPartyErr = dfns.DfnsInternalError
User struct {
Expand All @@ -54,6 +59,7 @@ const (
AuthorizationHeaderCtxValue = dfns.AuthHeaderCtxValue
AppIDHeaderCtxValue = dfns.AppIDCtxValue
registrationUrl = "/auth/registration/delegated"
completeRegistrationUrl = "/auth/registration/enduser"
completeLoginUrl = "/auth/login"
delegatedLoginUrl = "/auth/login/delegated"
)
Expand All @@ -71,16 +77,23 @@ var (
Err2FARequired = errors.New("2FA required")
ErrAuthenticatorRequirementsNotMet = errors.New("authenticator requirements not met")
ErrUserNotFound = storage.ErrNotFound
ErrInvalidFollowees = errors.New("invalid followees")
ErrInvalidUserSignature = errors.New("invalid user signature")
ErrInvalidUsername = dfns.ErrInvalidUsername
)

const (
applicationYamlKey = "accounts"
clientIPCtxValueKey = "clientIPCtxValueKey"
confirmationCodeLength = 6
applicationYamlKey = "accounts"
clientIPCtxValueKey = "clientIPCtxValueKey"
userSignatureCtxValueKey = "userSignatureCtxValueKey"
confirmationCodeLength = 6
)

//go:embed DDL.sql
var ddl string
var (
//go:embed DDL.sql
ddl string
errSignatureRequired = errors.New("signature is required")
)

type (
accounts struct {
Expand All @@ -96,27 +109,31 @@ type (
user struct {
CreatedAt *time.Time
UpdatedAt *time.Time
Active2FAEmail *int `db:"active_2fa_email"`
Active2FAPhoneNumber *int `db:"active_2fa_phone_number"`
Active2FATotpAuthenticator *int `db:"active_2fa_totp_authenticator"`
ID string
Username string
MasterPubKey string `db:"master_pubkey"`
Email []string
PhoneNumber []string
TotpAuthenticatorSecret []string
IONConnectRelays []string
Clients []string
Active2FAEmail []bool `db:"active_2fa_email"`
Active2FAPhoneNumber []bool `db:"active_2fa_phone_number"`
Active2FATotpAuthenticator []bool `db:"active_2fa_totp_authenticator"`
}
twoFACode struct {
CreatedAt *time.Time
ConfirmedAt *time.Time
UserID string
Option TwoFAOptionEnum
DeliverTo string
Code string
CreatedAt *time.Time
ConfirmedAt *time.Time
UserID string
Option TwoFAOptionEnum
DeliverToIdx *int `db:"deliver_to_idx"`
DeliverTo string
Code string
}
config struct {
EmailExpiration stdlibtime.Duration `yaml:"emailExpiration" mapstructure:"emailExpiration"`
SMSExpiration stdlibtime.Duration `yaml:"smsExpiration" mapstructure:"smsExpiration"`
EmailExpiration stdlibtime.Duration `yaml:"emailExpiration" mapstructure:"emailExpiration"`
SMSExpiration stdlibtime.Duration `yaml:"smsExpiration" mapstructure:"smsExpiration"`
UserSignatureExpiration stdlibtime.Duration `yaml:"userSignatureExpiration" mapstructure:"userSignatureExpiration"`
Max2FACount int `yaml:"max2FACount" mapstructure:"max2FACount"`
}
)
13 changes: 10 additions & 3 deletions accounts/delegated_rp.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,31 @@ package accounts
import (
"context"
"net/http"
"strings"

"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"

"github.com/ice-blockchain/heimdall/accounts/internal/dfns"
)

func (a *accounts) ProxyDelegatedRelyingParty(ctx context.Context, rw http.ResponseWriter, r *http.Request) {
a.delegatedRPClient.ProxyCall(ctx, rw, r)
}

func (a *accounts) StartDelegatedRecovery(ctx context.Context, username, credentialID string, codes map[TwoFAOptionEnum]string) (*StartedDelegatedRecovery, error) {
func (a *accounts) StartDelegatedRecovery(ctx context.Context, username, credentialID string, codes map[TwoFAOptionWithAddr]string) (*StartedDelegatedRecovery, error) {
username = strings.ToLower(username)
if !dfns.UsernameRegexp.MatchString(username) {
return nil, errors.Wrapf(dfns.ErrInvalidUsername, "username must match %v", dfns.UsernameRegexp.String())
}
usr, err := a.getUserByUsername(ctx, username)
if err != nil {
return nil, errors.Wrapf(err, "failed to get user 2FA state for username %v", username)
}
if err = checkIfAll2FAProvided(usr, codes); err != nil {
if err = a.checkIfEnough2FAProvided(usr, codes); err != nil {
return nil, err //nolint:wrapcheck // tErr.
}
var rollbackCodes map[TwoFAOptionEnum]string
var rollbackCodes map[TwoFAOptionWithAddr]string
if rollbackCodes, err = a.verifyAndRedeem2FA(ctx, usr.ID, codes); err != nil {
return nil, errors.Wrapf(err, "failed to verify 2FA codes")
}
Expand Down
10 changes: 7 additions & 3 deletions accounts/internal/dfns/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import (
"io"
"net/http"
"net/http/httputil"
"regexp"
"sync"
stdlibtime "time"

"github.com/dfns/dfns-sdk-go/credentials"
"github.com/golang-jwt/jwt/v5"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/pkg/errors"

"github.com/ice-blockchain/heimdall/server"
"github.com/ice-blockchain/wintr/time"
Expand All @@ -23,7 +25,7 @@ type (
VerifyToken(ctx context.Context, token string) (server.Token, error)
}
DfnsClient interface {
ProxyCall(ctx context.Context, rw http.ResponseWriter, r *http.Request) (respBody io.Reader)
ProxyCall(ctx context.Context, rw http.ResponseWriter, r *http.Request) (status int, respBody io.Reader)
StartDelegatedRecovery(ctx context.Context, username string, credentialId string) (*StartedDelegatedRecovery, error)
GetUser(ctx context.Context, userID string) (*User, error)
VerifyWebhookSecret(fromWebhook string) bool
Expand Down Expand Up @@ -57,8 +59,10 @@ const (
)

var (
ErrInvalidToken = server.ErrInvalidToken
ErrExpiredToken = server.ErrExpiredToken
ErrInvalidToken = server.ErrInvalidToken
ErrExpiredToken = server.ErrExpiredToken
ErrInvalidUsername = errors.New("invalid username")
UsernameRegexp = regexp.MustCompile("^[a-z0-9._-]+$")
)

type (
Expand Down
11 changes: 7 additions & 4 deletions accounts/internal/dfns/dfns.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ func (c *dfnsClient) mustListWebhooks(ctx context.Context) []webhook {
return filteredItems
}

func (c *dfnsClient) ProxyCall(ctx context.Context, rw http.ResponseWriter, req *http.Request) io.Reader {
func (c *dfnsClient) ProxyCall(ctx context.Context, rw http.ResponseWriter, req *http.Request) (status int, responseBody io.Reader) {
respBody := bytes.NewBuffer([]byte{})
applicationID := req.Header.Get(clientIDHeader)
if applicationID == "" {
Expand Down Expand Up @@ -273,7 +273,7 @@ func (c *dfnsClient) ProxyCall(ctx context.Context, rw http.ResponseWriter, req
resp, extendErr = json.Marshal(extendErrBody)
rw.Write(resp)

return bytes.NewBuffer(resp)
return extendErrBody.HTTPStatus, bytes.NewBuffer(resp)
}
rb := &proxyResponseBody{ResponseWriter: rw, Body: respBody}
if c.urlRequiresServiceAccountSignature(req.URL.Path) {
Expand All @@ -289,7 +289,7 @@ func (c *dfnsClient) ProxyCall(ctx context.Context, rw http.ResponseWriter, req
log.Error(errors.Wrapf(buildDfnsError(rb.Status, req.Method, bodyData), "dfns req to %v %v ended up with %v", req.Method, req.URL.Path, rb.Status))
}

return respBody
return rb.Status, respBody
}

func (c *dfnsClient) modifyResponse(r *http.Response) error {
Expand Down Expand Up @@ -391,7 +391,7 @@ func extendRequestWith[ReqBody any](req *http.Request, extendFn func(*ReqBody) e
}
if err = extendFn(&content); err != nil {
var errWithStatus *DfnsInternalError
if errors.As(err, errWithStatus) {
if errors.As(err, &errWithStatus) {
return errWithStatus, err
}
return &DfnsInternalError{HTTPStatus: http.StatusBadRequest, Message: fmt.Sprintf("validation failed: %v", err.Error())}, errors.Wrapf(err, "validation failed")
Expand Down Expand Up @@ -427,6 +427,9 @@ func (c *dfnsClient) updateRegisterReqBodyWithEndUser(req *http.Request) (resp *
Email string `json:"email"`
Kind string `json:"kind"`
}) error {
if !UsernameRegexp.MatchString(content.Email) {
return errors.Wrapf(ErrInvalidUsername, "must match %v", UsernameRegexp.String())
}
content.Kind = "EndUser"
return nil
})
Expand Down
2 changes: 1 addition & 1 deletion accounts/internal/dfns/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type DfnsInternalError struct {
Context map[string]interface{} `json:"context,omitempty"`
raw string
Message string `json:"message"`
HTTPStatus int `json:"httpStatus"`
HTTPStatus int `json:"httpStatus,omitempty"`
}

func ParseErrAsDfnsInternalErr(err error) error {
Expand Down
28 changes: 28 additions & 0 deletions accounts/internal/dfns/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,34 @@ func ExtractUser(res map[string]any, usernameField string) (userID, username str
return
}

func ExtractWalletPubKey(res map[string]any) (walletPubKey string) {
if walletsI, hasWallets := res["wallets"]; hasWallets {
wallets := walletsI.([]any)
for _, walletI := range wallets {
if walletI != nil {
wallet := walletI.(map[string]any)
if nameI, hasName := wallet["name"]; hasName && nameI != nil {
if name, ok := nameI.(string); !ok || name != defaultWalletName {
continue
}
}
if networkI, hasNetwork := wallet["network"]; hasNetwork && networkI != nil {
if network, ok := networkI.(string); !ok || network != defaultWalletNetwork {
continue
}
}
if keyI, hasKey := wallet["signingKey"]; hasKey && keyI != nil {
key := keyI.(map[string]any)
if pubkey, hasPk := key["publicKey"]; hasPk {
walletPubKey = pubkey.(string)
}
}
}
}
}
return walletPubKey
}

func (c *dfnsClient) GetUser(ctx context.Context, userID string) (*User, error) {
headers := http.Header{}
headers.Set(appIDHeader, appID(ctx))
Expand Down
Loading

0 comments on commit b9eaaa6

Please sign in to comment.