Skip to content

Commit

Permalink
Merge pull request #153 from sokolovstas/tfa-api-client
Browse files Browse the repository at this point in the history
WIP: 2FA Api
  • Loading branch information
erudenko authored Apr 30, 2021
2 parents 2f6fc03 + 6e7c858 commit 3d230f8
Show file tree
Hide file tree
Showing 10 changed files with 189 additions and 40 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ require (
github.com/rs/cors v1.6.0
github.com/rs/xid v1.2.1
github.com/sfreiberg/gotwilio v0.0.0-20190708190155-499f54b30211
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/urfave/negroni v1.0.0
github.com/xlzd/gotp v0.0.0-20181030022105-c8557ba2c119
go.etcd.io/etcd v3.3.25+incompatible
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/soheilhy/cmux v0.1.4 h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E=
Expand Down
5 changes: 4 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/madappgang/identifo/server/dynamodb"
"github.com/madappgang/identifo/server/fake"
"github.com/madappgang/identifo/server/mgo"
"github.com/rs/cors"
)

func main() {
Expand Down Expand Up @@ -73,7 +74,9 @@ func initServer(configStorage model.ConfigurationStorage) model.Server {
log.Panicln("Cannot init database composer:", err)
}

srv, err := server.NewServer(server.ServerSettings, dbComposer, configStorage, nil)
srv, err := server.NewServer(server.ServerSettings, dbComposer, configStorage, &model.CorsOptions{
API: &cors.Options{AllowedHeaders: []string{"*", "x-identifo-clientid"}, AllowedMethods: []string{"HEAD", "GET", "POST", "PUT", "PATCH", "DELETE"}},
})
if err != nil {
log.Panicln("Cannot init server:", err)
}
Expand Down
22 changes: 22 additions & 0 deletions model/user_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"log"
"regexp"
"strings"
"time"

"golang.org/x/crypto/bcrypt"
Expand Down Expand Up @@ -63,6 +64,14 @@ type User struct {
FederatedIDs []string `json:"federated_ids,omitempty" bson:"federated_i_ds,omitempty"`
}

func maskLeft(s string, hideFraction int) string {
rs := []rune(s)
for i := 0; i < len(rs)-len(rs)/hideFraction; i++ {
rs[i] = '*'
}
return string(rs)
}

// Sanitized returns data structure without sensitive information
func (u User) Sanitized() User {
u.Pswd = ""
Expand All @@ -72,6 +81,19 @@ func (u User) Sanitized() User {
return u
}

func (u User) SanitizedTFA() User {
u.Sanitized()
if len(u.Email) > 0 {
emailParts := strings.Split(u.Email, "@")
u.Email = maskLeft(emailParts[0], 2) + "@" + maskLeft(emailParts[1], 2)
}

if len(u.Phone) > 0 {
u.Phone = maskLeft(u.Phone, 3)
}
return u
}

// Deanonimized returns model with all fields set for deanonimized user
func (u User) Deanonimized() User {
u.Anonymous = false
Expand Down
42 changes: 34 additions & 8 deletions web/api/2fa.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"encoding/base64"
"errors"
"fmt"
"net/http"
Expand All @@ -11,13 +12,16 @@ import (
jwtService "github.com/madappgang/identifo/jwt/service"
"github.com/madappgang/identifo/model"
"github.com/madappgang/identifo/web/middleware"
qrcode "github.com/skip2/go-qrcode"
"github.com/xlzd/gotp"
)

// EnableTFA enables two-factor authentication for the user.
func (ar *Router) EnableTFA() http.HandlerFunc {
type tfaSecret struct {
TFASecret string `json:"tfa_secret"`
AccessToken string `json:"access_token,omitempty"`
ProvisioningURI string `json:"provisioning_uri,omitempty"`
ProvisioningQR string `json:"provisioning_qr,omitempty"`
}

return func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -75,17 +79,39 @@ func (ar *Router) EnableTFA() http.HandlerFunc {
return
}

tokenPayload, err := ar.getTokenPayloadForApp(app, user)
if err != nil {
ar.Error(w, ErrorAPIAppAccessTokenNotCreated, http.StatusInternalServerError, err.Error(), "EnableTFA.accessToken")
return
}

accessToken, _, err := ar.loginUser(user, []string{}, app, false, true, tokenPayload)
if err != nil {
ar.Error(w, ErrorAPIAppAccessTokenNotCreated, http.StatusInternalServerError, err.Error(), "EnableTFA.accessToken")
return
}

switch ar.tfaType {
case model.TFATypeApp:
// TODO: we need validation flow for TOTP codes
// user sees the secret as QR code, then they should use the app
// to enter those secret to the authentication app
// then use the TOTP from the app to validate the code
// after the TOTP is validate - the TFA is counted as enabled
ar.ServeJSON(w, http.StatusOK, &tfaSecret{TFASecret: user.TFAInfo.Secret})
uri := gotp.NewDefaultTOTP(user.TFAInfo.Secret).ProvisioningUri(user.Username, app.Name)

var png []byte
png, err := qrcode.Encode(uri, qrcode.Medium, 256)
if err != nil {
ar.Error(w, ErrorAPIAppAccessTokenNotCreated, http.StatusInternalServerError, err.Error(), "EnableTFA.QRgenerate")
return
}
encoded := base64.StdEncoding.EncodeToString(png)

ar.ServeJSON(w, http.StatusOK, &tfaSecret{ProvisioningURI: uri, ProvisioningQR: encoded, AccessToken: accessToken})
return
case model.TFATypeSMS, model.TFATypeEmail:
ar.ServeJSON(w, http.StatusOK, &tfaSecret{TFASecret: ""})
if err := ar.sendOTPCode(user); err != nil {
ar.Error(w, ErrorAPIRequestUnableToSendOTP, http.StatusInternalServerError, err.Error(), "EnableTFA.sendOTP")
return
}

ar.ServeJSON(w, http.StatusOK, &tfaSecret{AccessToken: accessToken})
return
}
ar.Error(w, ErrorAPIInternalServerError, http.StatusInternalServerError, fmt.Sprintf("Unknown tfa type '%s'", ar.tfaType), "switch.tfaType")
Expand Down
43 changes: 43 additions & 0 deletions web/api/app_settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package api

import (
"net/http"

"github.com/madappgang/identifo/web/middleware"
)

type appSettings struct {
AnonymousResitrationAllowed bool `json:"anonymousResitrationAllowed"`
Active bool `json:"active"`
Description string `json:"description"`
Id string `json:"id"`
NewUserDefaultRole string `json:"newUserDefaultRole"`
Offline bool `json:"offline"`
RegistrationForbidden bool `json:"registrationForbidden"`
TfaType string `json:"tfaType"`
}

// LoginWithPassword logs user in with username and password.
func (ar *Router) GetAppSettings() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
app := middleware.AppFromContext(r.Context())
if len(app.ID) == 0 {
ar.logger.Println("Error getting App")
ar.Error(w, ErrorAPIRequestAppIDInvalid, http.StatusBadRequest, "App is not in context.", "LoginWithPassword.AppFromContext")
return
}

result := appSettings{
AnonymousResitrationAllowed: app.AnonymousRegistrationAllowed,
Active: app.Active,
Description: app.Description,
Id: app.ID,
NewUserDefaultRole: app.NewUserDefaultRole,
Offline: app.Offline,
RegistrationForbidden: app.RegistrationForbidden,
TfaType: string(ar.tfaType),
}

ar.ServeJSON(w, http.StatusOK, result)
}
}
27 changes: 15 additions & 12 deletions web/api/appsecret.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/http"
"strings"

"github.com/madappgang/identifo/model"
"github.com/madappgang/identifo/web/middleware"
"github.com/urfave/negroni"
)
Expand All @@ -34,14 +35,6 @@ func (ar *Router) SignatureHandler() negroni.HandlerFunc {
return
}

// Read request signature from header and decode it.
reqMAC := extractSignature(r.Header.Get(SignatureHeaderKey))
if reqMAC == nil {
ar.logger.Println("Error extracting signature")
ar.Error(rw, ErrorAPIRequestSignatureInvalid, http.StatusBadRequest, "", "SignatureHandler.extractSignature")
return
}

var body []byte
t := r.Header.Get(TimestampHeaderKey)

Expand All @@ -63,10 +56,20 @@ func (ar *Router) SignatureHandler() negroni.HandlerFunc {
body = b
}

if err := validateBodySignature(body, reqMAC, []byte(app.Secret)); err != nil {
ar.logger.Printf("Error validating request signature: %v\n", err)
ar.Error(rw, ErrorAPIRequestSignatureInvalid, http.StatusBadRequest, err.Error(), "SignatureHandler.validateBodySignature")
return
if app.Type != model.Web {
// Read request signature from header and decode it.
reqMAC := extractSignature(r.Header.Get(SignatureHeaderKey))
if reqMAC == nil {
ar.logger.Println("Error extracting signature")
ar.Error(rw, ErrorAPIRequestSignatureInvalid, http.StatusBadRequest, "", "SignatureHandler.extractSignature")
return

}
if err := validateBodySignature(body, reqMAC, []byte(app.Secret)); err != nil {
ar.logger.Printf("Error validating request signature: %v\n", err)
ar.Error(rw, ErrorAPIRequestSignatureInvalid, http.StatusBadRequest, err.Error(), "SignatureHandler.validateBodySignature")
return
}
}

if r.Method != "GET" && r.Body != http.NoBody {
Expand Down
61 changes: 44 additions & 17 deletions web/api/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import (
)

var (
errPleaseEnableTFA = fmt.Errorf("Please enable two-factor authentication to be able to use this app")
errPleaseDisableTFA = fmt.Errorf("Please disable two-factor authentication to be able to use this app")
errPleaseEnableTFA = fmt.Errorf("Please enable two-factor authentication to be able to use this app")
errPleaseSetPhoneTFA = fmt.Errorf("Please set phone for two-factor authentication to be able to use this app")
errPleaseSetEmailTFA = fmt.Errorf("Please set email for two-factor authentication to be able to use this app")
errPleaseDisableTFA = fmt.Errorf("Please disable two-factor authentication to be able to use this app")
)

const (
Expand All @@ -29,6 +31,8 @@ type AuthResponse struct {
AccessToken string `json:"access_token,omitempty" bson:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty" bson:"refresh_token,omitempty"`
User model.User `json:"user,omitempty" bson:"user,omitempty"`
Require2FA bool `json:"require_2fa" bson:"require_2fa"`
Enabled2FA bool `json:"enabled_2fa" bson:"enabled_2fa"`
}

type loginData struct {
Expand Down Expand Up @@ -100,8 +104,8 @@ func (ar *Router) LoginWithPassword() http.HandlerFunc {
}

// Check if we should require user to authenticate with 2FA.
require2FA, err := ar.check2FA(w, app.TFAStatus, user.TFAInfo)
if err != nil {
require2FA, enabled2FA, err := ar.check2FA(w, app.TFAStatus, ar.tfaType, user)
if !require2FA && enabled2FA && err != nil {
return
}

Expand All @@ -122,9 +126,11 @@ func (ar *Router) LoginWithPassword() http.HandlerFunc {
result := AuthResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
Require2FA: require2FA,
Enabled2FA: enabled2FA,
}

if require2FA {
if require2FA && enabled2FA {
if err := ar.sendOTPCode(user); err != nil {
ar.Error(w, ErrorAPIRequestUnableToSendOTP, http.StatusInternalServerError, err.Error(), "LoginWithPassword.loginUser")
return
Expand Down Expand Up @@ -173,6 +179,19 @@ func (ar *Router) IsLoggedIn() http.HandlerFunc {
}
}

func (ar *Router) GetUser() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := tokenFromContext(r.Context()).UserID()
user, err := ar.userStorage.UserByID(userID)
if err != nil {
ar.Error(w, ErrorAPIUserNotFound, http.StatusUnauthorized, err.Error(), "UpdateUser.UserByID")
return
}
ar.ServeJSON(w, http.StatusOK, user.SanitizedTFA())
}

}

// getTokenPayloadForApp get additional token payload data
func (ar *Router) getTokenPayloadForApp(app model.AppData, user model.User) (map[string]interface{}, error) {
if app.TokenPayloadService == model.TokenPayloadServiceHttp {
Expand Down Expand Up @@ -223,28 +242,36 @@ func (ar *Router) loginUser(user model.User, scopes []string, app model.AppData,

// check2FA checks correspondence between app's TFAstatus and user's TFAInfo,
// and decides if we require two-factor authentication after all checks are successfully passed.
func (ar *Router) check2FA(w http.ResponseWriter, appTFAStatus model.TFAStatus, userTFAInfo model.TFAInfo) (bool, error) {
if appTFAStatus == model.TFAStatusMandatory && !userTFAInfo.IsEnabled {
ar.Error(w, ErrorAPIRequestPleaseEnableTFA, http.StatusBadRequest, errPleaseEnableTFA.Error(), "check2FA.mandatory")
return false, errPleaseEnableTFA
func (ar *Router) check2FA(w http.ResponseWriter, appTFAStatus model.TFAStatus, serverTFAType model.TFAType, user model.User) (bool, bool, error) {
if appTFAStatus == model.TFAStatusMandatory && !user.TFAInfo.IsEnabled {
// ar.Error(w, ErrorAPIRequestPleaseEnableTFA, http.StatusBadRequest, errPleaseEnableTFA.Error(), "check2FA.mandatory")
return true, false, errPleaseEnableTFA
}

if appTFAStatus == model.TFAStatusDisabled && userTFAInfo.IsEnabled {
if appTFAStatus == model.TFAStatusDisabled && user.TFAInfo.IsEnabled {
ar.Error(w, ErrorAPIRequestPleaseDisableTFA, http.StatusBadRequest, errPleaseDisableTFA.Error(), "check2FA.appDisabled_userEnabled")
return false, errPleaseDisableTFA
return false, true, errPleaseDisableTFA
}

// Request two-factor auth if user enabled it and app supports it.
if userTFAInfo.IsEnabled && appTFAStatus != model.TFAStatusDisabled {
if userTFAInfo.Secret == "" {
if user.TFAInfo.IsEnabled && appTFAStatus != model.TFAStatusDisabled {
if user.Phone == "" && serverTFAType == model.TFATypeSMS {
// Server required sms tfa but user phone is empty
return true, false, errPleaseSetPhoneTFA
}
if user.Email == "" && serverTFAType == model.TFATypeEmail {
// Server required email tfa but user email is empty
return true, false, errPleaseSetEmailTFA
}
if user.TFAInfo.Secret == "" {
// Then admin must have enabled TFA for this user manually.
// User must obtain TFA secret, i.e send EnableTFA request.
ar.Error(w, ErrorAPIRequestPleaseEnableTFA, http.StatusConflict, errPleaseEnableTFA.Error(), "check2FA.pleaseEnable")
return false, errPleaseEnableTFA
// ar.Error(w, ErrorAPIRequestPleaseEnableTFA, http.StatusConflict, errPleaseEnableTFA.Error(), "check2FA.pleaseEnable")
return true, false, errPleaseEnableTFA
}
return true, nil
return true, true, nil
}
return false, nil
return false, false, nil
}

func (ar *Router) sendTFACodeInSMS(phone, otp string) error {
Expand Down
4 changes: 3 additions & 1 deletion web/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ func (ar *Router) initRoutes() {
auth.Path(`/{register:register/?}`).HandlerFunc(ar.RegisterWithPassword()).Methods("POST")
auth.Path(`/{reset_password:reset_password/?}`).HandlerFunc(ar.RequestResetPassword()).Methods("POST")

auth.Path(`/{app_settings:app_settings/?}`).HandlerFunc(ar.GetAppSettings()).Methods("GET")

auth.Path(`/{token:token/?}`).Handler(negroni.New(
ar.Token(model.TokenTypeRefresh, nil),
negroni.Wrap(ar.RefreshTokens()),
Expand Down Expand Up @@ -69,7 +71,7 @@ func (ar *Router) initRoutes() {
ar.Token(model.TokenTypeAccess, nil),
negroni.Wrap(meRouter),
))
meRouter.Path("").HandlerFunc(ar.IsLoggedIn()).Methods("GET")
meRouter.Path("").HandlerFunc(ar.GetUser()).Methods("GET")
meRouter.Path("").HandlerFunc(ar.UpdateUser()).Methods("PUT")
meRouter.Path(`/{logout:logout/?}`).HandlerFunc(ar.Logout()).Methods("POST")

Expand Down
Loading

0 comments on commit 3d230f8

Please sign in to comment.