diff --git a/application/authentication/business.go b/application/authentication/business.go new file mode 100644 index 0000000..3b18282 --- /dev/null +++ b/application/authentication/business.go @@ -0,0 +1,308 @@ +// Copyright (c) 2022 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package authentication + +import ( + "context" + + "github.com/google/uuid" + database "github.com/isaqueveras/powersso/database/postgres" + domain "github.com/isaqueveras/powersso/domain/authentication" + infra "github.com/isaqueveras/powersso/infrastructure/persistencie/authentication" + "github.com/isaqueveras/powersso/oops" + "github.com/isaqueveras/powersso/tokens" + "github.com/isaqueveras/powersso/utils" +) + +// CreateAccount is the business logic for the user register +func CreateAccount(ctx context.Context, in *domain.CreateAccount) (url *string, err error) { + var tx *database.Transaction + if tx, err = database.NewTransaction(ctx, false); err != nil { + return nil, oops.Err(err) + } + defer tx.Rollback() + + if err = in.Prepare(); err != nil { + return nil, oops.Err(err) + } + + userRepository := infra.NewUserRepository(tx) + if err = userRepository.AccountExists(in.Email); err != nil { + return nil, oops.Err(err) + } + + authRepository := infra.NewAuthRepository(tx) + var userID *uuid.UUID + if userID, err = authRepository.CreateAccount(in); err != nil { + return nil, oops.Err(err) + } + + service := domain.NewAuthService(infra.NewFlagRepo(tx), infra.NewOTPRepo(tx)) + if err = service.Configure2FA(userID); err != nil { + return nil, oops.Err(err) + } + + if url, err = service.GenerateQrCode2FA(userID); err != nil { + return nil, oops.Err(err) + } + + if err = tx.Commit(); err != nil { + return nil, oops.Err(err) + } + + return +} + +// Login is the business logic for the user login +func Login(ctx context.Context, in *domain.Login) (*domain.Session, error) { + tx, err := database.NewTransaction(ctx, false) + if err != nil { + return nil, oops.Err(err) + } + defer tx.Rollback() + + var ( + repoAuth = infra.NewAuthRepository(tx) + repoUser = infra.NewUserRepository(tx) + repoSession = infra.NewSessionRepository(tx) + ) + + user := &domain.User{Email: in.Email} + if err = repoUser.GetUser(user); err != nil { + return nil, oops.Err(err) + } + + if !user.IsActive() { + return nil, domain.ErrUserNotExists() + } + + if !user.OTPConfigured() { + return nil, domain.ErrAuthentication2factorNotConfigured() + } + + if user.IsBlocked() { + return nil, domain.ErrUserBlockedTemporarily() + } + + if err = in.ComparePasswords(user.Password, user.Key); err != nil { + if errAttempts := repoAuth.AddAttempts(user.ID); errAttempts != nil { + return nil, oops.Err(errAttempts) + } + if errAttempts := tx.Commit(); errAttempts != nil { + return nil, oops.Err(errAttempts) + } + return nil, oops.Err(err) + } + + if err = utils.ValidateToken(user.OTPToken, in.OTP); err != nil { + return nil, domain.ErrOTPTokenInvalid() + } + + var sessionID *uuid.UUID + if sessionID, err = repoSession.Create(user.ID, in.ClientIP, in.UserAgent); err != nil { + return nil, oops.Err(err) + } + + var token *string + if token, err = tokens.NewAuthToken(user, sessionID); err != nil { + return nil, oops.Err(err) + } + + if err = tx.Commit(); err != nil { + return nil, oops.Err(err) + } + + return &domain.Session{ + SessionID: sessionID, + Level: user.Level, + UserID: user.ID, + Email: user.Email, + FirstName: user.FirstName, + LastName: user.LastName, + CreatedAt: user.CreatedAt, + Token: token, + }, nil +} + +// Logout is the business logic for the user logout +func Logout(ctx context.Context, sessionID *uuid.UUID) (err error) { + var tx *database.Transaction + if tx, err = database.NewTransaction(ctx, false); err != nil { + return oops.Err(err) + } + defer tx.Rollback() + + if err = infra.NewSessionRepository(tx).Delete(sessionID); err != nil { + return oops.Err(err) + } + + if err = tx.Commit(); err != nil { + return oops.Err(err) + } + + return +} + +// LoginSteps is the business logic needed to retrieve needed steps for log a user in +func LoginSteps(ctx context.Context, email *string) (res *domain.Steps, err error) { + var tx *database.Transaction + if tx, err = database.NewTransaction(ctx, true); err != nil { + return nil, oops.Err(err) + } + defer tx.Rollback() + + return infra.NewAuthRepository(tx).LoginSteps(email) +} + +// Configure2FA performs business logic to configure otp for a user +func Configure2FA(ctx context.Context, userID *uuid.UUID) (err error) { + var tx *database.Transaction + if tx, err = database.NewTransaction(ctx, false); err != nil { + return oops.Err(err) + } + defer tx.Rollback() + + var ( + repoOTP = infra.NewOTPRepo(tx) + repoFlag = infra.NewFlagRepo(tx) + service = domain.NewAuthService(repoFlag, repoOTP) + ) + + if err = service.Configure2FA(userID); err != nil { + return oops.Err(err) + } + + if err = tx.Commit(); err != nil { + return oops.Err(err) + } + + return +} + +// Unconfigure2FA performs business logic to unconfigure otp for a user +func Unconfigure2FA(ctx context.Context, userID *uuid.UUID) (err error) { + var tx *database.Transaction + if tx, err = database.NewTransaction(ctx, false); err != nil { + return oops.Err(err) + } + defer tx.Rollback() + + repoFlag := infra.NewFlagRepo(tx) + flag, err := repoFlag.Get(userID) + if err != nil { + return oops.Err(err) + } + + if err = repoFlag.Set(userID, (domain.Flag(*flag))&(^domain.FlagOTPEnable)); err != nil { + return oops.Err(err) + } + + if err = repoFlag.Set(userID, (domain.Flag(*flag))&(^domain.FlagOTPSetup)); err != nil { + return oops.Err(err) + } + + repoOTP := infra.NewOTPRepo(tx) + if err = repoOTP.SetToken(userID, nil); err != nil { + return oops.Err(err) + } + + if err = tx.Commit(); err != nil { + return oops.Err(err) + } + + return +} + +// GetQRCode2FA performs business logic to get qrcode url +func GetQRCode2FA(ctx context.Context, userID *uuid.UUID) (url *string, err error) { + var tx *database.Transaction + if tx, err = database.NewTransaction(ctx, true); err != nil { + return nil, oops.Err(err) + } + defer tx.Rollback() + + var ( + repoFlag = infra.NewFlagRepo(tx) + repoOTP = infra.NewOTPRepo(tx) + service = domain.NewAuthService(repoFlag, repoOTP) + ) + + return service.GenerateQrCode2FA(userID) +} + +// DisableUser is the business logic for disable user +func DisableUser(ctx context.Context, userUUID *uuid.UUID) error { + tx, err := database.NewTransaction(ctx, false) + if err != nil { + return oops.Err(err) + } + defer tx.Rollback() + + repo := infra.NewUserRepository(tx) + if err = repo.DisableUser(userUUID); err != nil { + return oops.Err(err) + } + + if err = tx.Commit(); err != nil { + return oops.Err(err) + } + + return nil +} + +// ChangePassword is the busines logic for change passoword +func ChangePassword(ctx context.Context, in *domain.ChangePassword) (err error) { + tx, err := database.NewTransaction(ctx, false) + if err != nil { + return oops.Err(err) + } + defer tx.Rollback() + + repoUser := infra.NewUserRepository(tx) + repoSession := infra.NewSessionRepository(tx) + + user := domain.User{ID: in.UserID} + if err = repoUser.GetUser(&user); err != nil { + return oops.Err(err) + } + + if !user.OTPConfigured() { + return oops.New("2-factor authentication not configured") + } + + // Validate code otp + if err = utils.ValidateToken(user.OTPToken, in.CodeOTP); err != nil { + return oops.New("2-factor authentication code is invalid") + } + + // Generate new password crypto + gen := &domain.CreateAccount{Password: in.Password} + if err = gen.GeneratePassword(); err != nil { + return err + } + + // Change user password + in.Password, in.Key = gen.Password, gen.Key + if err = repoUser.ChangePassword(in); err != nil { + return oops.Err(err) + } + + // Getting all active user sessions + var sessions []*uuid.UUID + if sessions, err = repoSession.Get(in.UserID); err != nil { + return oops.Err(err) + } + + // Disabling all active user sessions + if err = repoSession.Delete(sessions...); err != nil { + return oops.Err(err) + } + + if err = tx.Commit(); err != nil { + return oops.Err(err) + } + + return +} diff --git a/delivery/http/authentication/handler.go b/delivery/http/authentication/handler.go new file mode 100644 index 0000000..01dac15 --- /dev/null +++ b/delivery/http/authentication/handler.go @@ -0,0 +1,167 @@ +// Copyright (c) 2022 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package authentication + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + app "github.com/isaqueveras/powersso/application/authentication" + domain "github.com/isaqueveras/powersso/domain/authentication" + "github.com/isaqueveras/powersso/i18n" + "github.com/isaqueveras/powersso/middleware" + "github.com/isaqueveras/powersso/oops" + "github.com/isaqueveras/powersso/utils" +) + +// @Router /v1/auth/create_account [POST] +func createAccount(ctx *gin.Context) { + var input domain.CreateAccount + if err := ctx.ShouldBindJSON(&input); err != nil { + oops.Handling(ctx, err) + return + } + + url, err := app.CreateAccount(ctx, &input) + if err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusCreated, map[string]string{ + "url": *url, + "message": i18n.Value("create_account.message"), + "instructions": i18n.Value("create_account.instructions"), + }) +} + +// @Router /v1/auth/login [POST] +func login(ctx *gin.Context) { + var input domain.Login + if err := ctx.ShouldBindJSON(&input); err != nil { + oops.Handling(ctx, err) + return + } + + input.ClientIP = utils.Pointer(ctx.ClientIP()) + input.UserAgent = utils.Pointer(ctx.Request.UserAgent()) + input.Validate() + + output, err := app.Login(ctx, &input) + if err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusOK, output) +} + +// @Router /v1/auth/change_password [put] +func changePassword(ctx *gin.Context) { + in := &domain.ChangePassword{} + if err := ctx.ShouldBindJSON(in); err != nil { + oops.Handling(ctx, err) + return + } + + if ok := in.ValidatePassword(); !ok { + oops.Handling(ctx, oops.New("Invalid passwords")) + return + } + + if err := app.ChangePassword(ctx, in); err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusOK, nil) +} + +// @Router /v1/auth/logout [DELETE] +func logout(ctx *gin.Context) { + if err := app.Logout(ctx, utils.Pointer(middleware.GetSession(ctx).SessionID)); err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusNoContent, utils.NoContent{}) +} + +// @Router /v1/auth/login/steps [GET] +func loginSteps(ctx *gin.Context) { + res, err := app.LoginSteps(ctx, utils.Pointer(ctx.Query("email"))) + if err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusOK, res) +} + +// @Router /v1/auth/user/{user_id}/disable [PUT] +func disable(ctx *gin.Context) { + userID, err := uuid.Parse(ctx.Param("user_id")) + if err != nil { + oops.Handling(ctx, err) + return + } + + if err = app.DisableUser(ctx, &userID); err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusCreated, utils.NoContent{}) +} + +// @Router /v1/auth/user/{user_id}/otp/configure [POST] +func configure(ctx *gin.Context) { + userID, err := uuid.Parse(ctx.Param("user_id")) + if err != nil { + oops.Handling(ctx, err) + return + } + + if err = app.Configure2FA(ctx, &userID); err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusCreated, utils.NoContent{}) +} + +// @Router /v1/auth/user/{user_id}/otp/unconfigure [PUT] +func unconfigure(ctx *gin.Context) { + userID, err := uuid.Parse(ctx.Param("user_id")) + if err != nil { + oops.Handling(ctx, err) + return + } + + if err = app.Unconfigure2FA(ctx, &userID); err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusCreated, utils.NoContent{}) +} + +// @Router /v1/auth/user/{user_id}/otp/qrcode [GET] +func qrcode(ctx *gin.Context) { + userID, err := uuid.Parse(ctx.Param("user_id")) + if err != nil { + oops.Handling(ctx, err) + return + } + + var url *string + if url, err = app.GetQRCode2FA(ctx, &userID); err != nil { + oops.Handling(ctx, err) + return + } + + ctx.JSON(http.StatusOK, map[string]*string{"url": url}) +} diff --git a/delivery/http/authentication/handler_test.go b/delivery/http/authentication/handler_test.go new file mode 100644 index 0000000..9fe4599 --- /dev/null +++ b/delivery/http/authentication/handler_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2022 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package authentication + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "bou.ke/monkey" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/stretchr/testify/suite" + + "github.com/isaqueveras/powersso/application/authentication" + domain "github.com/isaqueveras/powersso/domain/authentication" + "github.com/isaqueveras/powersso/middleware" + "github.com/isaqueveras/powersso/oops" + "github.com/isaqueveras/powersso/utils" +) + +const sucessUserID = "9ec1b2a7-665c-47a7-b180-54f11f8a6122" + +func TestHandlerAuth(t *testing.T) { + suite.Run(t, new(testSuite)) +} + +type testSuite struct { + router *gin.Engine + + suite.Suite +} + +func (a *testSuite) SetupSuite() { + var handleUserLog = func() gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.Set("UID", sucessUserID) + ctx.Set("SESSION", jwt.MapClaims{ + "SessionID": "", + "UserID": sucessUserID, + "UserLevel": string(domain.AdminLevel), + "FirstName": "Janekin", + }) + } + } + + a.router = gin.New() + a.router.Use(middleware.RequestIdentifier(), handleUserLog()) + Router(a.router.Group("v1/auth")) + RouterAuthorization(a.router.Group("v1/auth")) + RouterAuthorization(a.router.Group("v1/auth/user/:user_id/otp")) +} + +func (a *testSuite) TestShouldCreateUser() { + monkey.Patch(authentication.CreateAccount, func(_ context.Context, _ *domain.CreateAccount) (*string, error) { + return utils.Pointer(""), nil + }) + defer monkey.Unpatch(authentication.CreateAccount) + + data, err := json.Marshal(map[string]interface{}{ + "first_name": "any_first_name", + "last_name": "any_last_name", + "email": "any@email.com", + "password": "any_password", + }) + a.Assert().Nil(err, oops.Err(err)) + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/create_account", bytes.NewBuffer(data)) + w := httptest.NewRecorder() + + a.router.ServeHTTP(w, req) + a.Assert().Equal(http.StatusCreated, w.Code) +} + +func (t *testSuite) TestShouldGetUrlQrCode() { + t.Run("Success", func() { + monkey.Patch(authentication.GetQRCode2FA, func(_ context.Context, _ *uuid.UUID) (*string, error) { + return nil, nil + }) + defer monkey.Unpatch(authentication.GetQRCode2FA) + + req := httptest.NewRequest(http.MethodGet, "/v1/auth/user/"+sucessUserID+"/otp/qrcode", nil) + w := httptest.NewRecorder() + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusOK, w.Code) + }) + + t.Run("Error::FetchAnotherUserURL", func() { + monkey.Patch(authentication.GetQRCode2FA, func(_ context.Context, _ *uuid.UUID) (*string, error) { + return nil, nil + }) + defer monkey.Unpatch(authentication.GetQRCode2FA) + + req := httptest.NewRequest(http.MethodGet, "/v1/auth/user/"+uuid.New().String()+"/otp/qrcode", nil) + w := httptest.NewRecorder() + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusForbidden, w.Code) + }) +} + +func (t *testSuite) TestShouldConfigure() { + t.Run("Success", func() { + monkey.Patch(authentication.Configure2FA, func(_ context.Context, _ *uuid.UUID) error { + return nil + }) + defer monkey.Unpatch(authentication.Configure2FA) + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/user/"+sucessUserID+"/otp/configure", nil) + w := httptest.NewRecorder() + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusCreated, w.Code) + }) + + t.Run("Error::FetchAnotherUserURL", func() { + monkey.Patch(authentication.Configure2FA, func(_ context.Context, _ *uuid.UUID) error { + return nil + }) + defer monkey.Unpatch(authentication.Configure2FA) + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/user/"+uuid.New().String()+"/otp/configure", nil) + w := httptest.NewRecorder() + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusForbidden, w.Code) + }) +} + +func (t *testSuite) TestShouldUnconfigure() { + t.Run("Success", func() { + monkey.Patch(authentication.Unconfigure2FA, func(_ context.Context, _ *uuid.UUID) error { + return nil + }) + defer monkey.Unpatch(authentication.Unconfigure2FA) + + var ( + req = httptest.NewRequest(http.MethodPut, "/v1/auth/user/"+sucessUserID+"/otp/unconfigure", nil) + w = httptest.NewRecorder() + ) + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusCreated, w.Code) + }) + + t.Run("Error::FetchAnotherUserURL", func() { + monkey.Patch(authentication.Unconfigure2FA, func(_ context.Context, _ *uuid.UUID) error { + return nil + }) + defer monkey.Unpatch(authentication.Unconfigure2FA) + + var ( + req = httptest.NewRequest(http.MethodPut, "/v1/auth/user/"+uuid.New().String()+"/otp/unconfigure", nil) + w = httptest.NewRecorder() + ) + + t.router.ServeHTTP(w, req) + t.Assert().Equal(http.StatusForbidden, w.Code) + }) +} diff --git a/delivery/http/authentication/router.go b/delivery/http/authentication/router.go new file mode 100644 index 0000000..71f9770 --- /dev/null +++ b/delivery/http/authentication/router.go @@ -0,0 +1,34 @@ +// Copyright (c) 2022 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package authentication + +import ( + "github.com/gin-gonic/gin" + + "github.com/isaqueveras/powersso/middleware" +) + +// Router is the router for the auth module. +func Router(r *gin.RouterGroup) { + r.POST("create_account", createAccount) + r.POST("login", login) + r.GET("login/steps", loginSteps) + r.PUT("change_password", changePassword) +} + +// RouterAuthorization is the router for the auth module. +func RouterAuthorization(r *gin.RouterGroup) { + r.DELETE("logout", logout) + + user := r.Group("user/:user_id") + user.PUT("disable", disable) + + otp := user.Group("otp") + otp.Use(middleware.Yourself()) + + otp.GET("qrcode", qrcode) + otp.POST("configure", configure) + otp.PUT("unconfigure", unconfigure) +} diff --git a/domain/authentication/errors.go b/domain/authentication/errors.go new file mode 100644 index 0000000..9584c37 --- /dev/null +++ b/domain/authentication/errors.go @@ -0,0 +1,47 @@ +// Copyright (c) 2022 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package authentication + +import ( + "net/http" + + "github.com/isaqueveras/powersso/i18n" + "github.com/isaqueveras/powersso/oops" +) + +// ErrUserExists creates and returns an error when the user already exists +func ErrUserExists() *oops.Error { + return oops.NewError(i18n.Value("errors.handling.err_user_exists"), http.StatusBadRequest) +} + +// ErrTokenIsNotValid creates and returns an error when the token is not valid +func ErrTokenIsNotValid() *oops.Error { + return oops.NewError(i18n.Value("errors.handling.err_token_is_not_valid"), http.StatusBadRequest) +} + +// ErrUserNotExists creates and returns an error when the user does not exists +func ErrUserNotExists() *oops.Error { + return oops.NewError(i18n.Value("errors.handling.err_user_not_exists"), http.StatusNotFound) +} + +// ErrEmailOrPasswordIsNotValid creates and returns an error when the email or password is not valid +func ErrEmailOrPasswordIsNotValid() *oops.Error { + return oops.NewError(i18n.Value("errors.handling.err_email_or_password_is_not_valid"), http.StatusBadRequest) +} + +// ErrUserBlockedTemporarily creates and returns an error when the user is blocked temporarily +func ErrUserBlockedTemporarily() *oops.Error { + return oops.NewError(i18n.Value("errors.handling.err_user_blocked_temporarily"), http.StatusForbidden) +} + +// ErrOTPTokenInvalid creates and returns an error when validate token OTP +func ErrOTPTokenInvalid() *oops.Error { + return oops.NewError(i18n.Value("errors.handling.err_a2f_invalid"), http.StatusForbidden) +} + +// ErrAuthentication2factorNotConfigured user with 2-factor authentication token not configured +func ErrAuthentication2factorNotConfigured() *oops.Error { + return oops.NewError(i18n.Value("errors.handling.err_otp_token_2_factor_authentication_not_configured"), http.StatusForbidden) +} diff --git a/domain/authentication/interface.go b/domain/authentication/interface.go new file mode 100644 index 0000000..9374bc8 --- /dev/null +++ b/domain/authentication/interface.go @@ -0,0 +1,47 @@ +// Copyright (c) 2022 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package authentication + +import "github.com/google/uuid" + +// IAuthService defines an interface for service methods to access the data layer +type IAuthService interface { + Configure2FA(userID *uuid.UUID) error + GenerateQrCode2FA(userID *uuid.UUID) (*string, error) +} + +// IAuth define an interface for data layer access methods +type IAuth interface { + CreateAccount(*CreateAccount) (userID *uuid.UUID, err error) + AddAttempts(userID *uuid.UUID) error + LoginSteps(email *string) (*Steps, error) +} + +// ISession define an interface for data layer access methods +type ISession interface { + Create(userID *uuid.UUID, clientIP, userAgent *string) (*uuid.UUID, error) + Delete(ids ...*uuid.UUID) error + Get(userID *uuid.UUID) ([]*uuid.UUID, error) +} + +// IFlag define an interface for data layer access methods +type IFlag interface { + Get(userID *uuid.UUID) (*int64, error) + Set(userID *uuid.UUID, flag Flag) error +} + +// IOTP define an interface for data layer access methods +type IOTP interface { + GetToken(userID *uuid.UUID) (*string, *string, error) + SetToken(userID *uuid.UUID, secret *string) error +} + +// IUser define an interface for data layer access methods +type IUser interface { + GetUser(*User) error + ChangePassword(*ChangePassword) error + AccountExists(email *string) error + DisableUser(userUUID *uuid.UUID) error +} diff --git a/domain/authentication/model.go b/domain/authentication/model.go new file mode 100644 index 0000000..8c7c60a --- /dev/null +++ b/domain/authentication/model.go @@ -0,0 +1,232 @@ +// Copyright (c) 2022 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package authentication + +import ( + "strings" + "time" + + "github.com/google/uuid" + "github.com/isaqueveras/powersso/config" + "github.com/isaqueveras/powersso/utils" + "golang.org/x/crypto/bcrypt" +) + +// Flag set the data type to flag the user +type Flag int + +const ( + // FlagEnabledAccount defines that the user has already activated his account + FlagEnabledAccount Flag = iota + 1 + // FlagOTPEnable defines that the user has OTP enabled + FlagOTPEnable + // FlagOTPSetup defines that the user has OTP configured + FlagOTPSetup +) + +// Level set data type to user level +type Level string + +const ( + // UserLevel is the user role + UserLevel Level = "user" + // AdminLevel is the admin role + AdminLevel Level = "admin" + // IntegrationLevel is the integration role + IntegrationLevel Level = "integration" +) + +const ( + // CostHashPasswordProduction is the cost of hashing password in production + CostHashPasswordProduction int = 14 + // CostHashPasswordDevelopment is the cost of hashing the password in development mode + CostHashPasswordDevelopment int = 1 +) + +// CreateAccount models the data to create an account +type CreateAccount struct { + FirstName *string `sql:"first_name" json:"first_name"` + LastName *string `sql:"last_name" json:"last_name"` + Email *string `sql:"email" json:"email"` + Password *string `sql:"password" json:"password"` + Key *string `sql:"key" json:"-"` + Level *Level `sql:"level" json:"-"` +} + +// Prepare prepare data for registration +func (rr *CreateAccount) Prepare() (err error) { + rr.Email = utils.Pointer(strings.ToLower(strings.TrimSpace(*rr.Email))) + + if err = rr.GeneratePassword(); err != nil { + return err + } + + return +} + +// RefreshTokenKey generates and sets new random token key. +// >> invalidate previously issued tokens +func (rr *CreateAccount) RefreshTokenKey() { + rr.Key = new(string) + rr.Key = utils.Pointer(utils.RandomString(50)) +} + +// GeneratePassword hash user password with bcrypt +func (rr *CreateAccount) GeneratePassword() error { + rr.Password = utils.Pointer(strings.TrimSpace(*rr.Password)) + rr.RefreshTokenKey() + + cost := CostHashPasswordDevelopment + if config.Get().Server.IsModeProduction() { + cost = CostHashPasswordProduction + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*rr.Key+*rr.Password), cost) + if err != nil { + return err + } + + rr.Password = utils.Pointer(string(hashedPassword)) + return nil +} + +// SanitizePassword sanitize user password +func (rr *CreateAccount) SanitizePassword() { + rr.Password = nil +} + +// ActivateAccount model the data to activate user account +type ActivateAccount struct { + ID *uuid.UUID `sql:"id"` + UserID *uuid.UUID `sql:"user_id"` + Used *bool `sql:"used"` + Valid *bool + ExpiresAt *time.Time `sql:"expires_at"` + CreatedAt *time.Time `sql:"created_at"` +} + +// IsValid check if the token is valid +func (a *ActivateAccount) IsValid() bool { + return (a.Used != nil && !*a.Used) && (a.Valid != nil && *a.Valid) +} + +// Steps contains login steps +type Steps struct { + Name *string + OTP *bool +} + +// User ... +type User struct { + ID *uuid.UUID + Email *string + Password *string `json:"-"` + FirstName *string + LastName *string + Flag *Flag + Level *Level + Blocked *bool + Key *string + Active *bool + OTPToken *string + OTPEnable *bool + OTPSetUp *bool + CreatedBy *uuid.UUID + CreatedAt *time.Time + LastLogin *time.Time +} + +// HasFlag return 'true' if has flag +func (u *User) HasFlag(flag Flag) bool { + return u.Flag != nil && *u.Flag&flag != 0 +} + +// IsActive check if the user has their account activated +func (u *User) IsActive() bool { + return u.Active != nil && *u.Active +} + +// IsBlocked check if the user has the account temporarily blocked +func (u *User) IsBlocked() bool { + return u.Blocked != nil && *u.Blocked +} + +// OTPConfigured checks if the user has the OTP token configured +func (u *User) OTPConfigured() bool { + enabled := u.Flag != nil && *u.Flag&FlagOTPEnable != 0 + setup := u.Flag != nil && *u.Flag&FlagOTPSetup != 0 + return enabled && setup +} + +// GetUserLevel returns the authentication token and duration by user level +func (u *User) GetUserLevel(s *config.Secrets) string { + keys := map[Level]string{ + UserLevel: s.User, + AdminLevel: s.Admin, + IntegrationLevel: s.Integration, + } + return keys[*u.Level] +} + +// Login models the data for the user to log in with their account +type Login struct { + Email *string `json:"email" binding:"required,lte=60,email"` + Password *string `json:"password" binding:"required,gte=6"` + OTP *string `json:"otp,omitempty"` + ClientIP *string `json:"-"` + UserAgent *string `json:"-"` +} + +// ChangePassword ... +type ChangePassword struct { + UserID *uuid.UUID `json:"user_id"` + Password *string `json:"password"` + ConfirmPassword *string `json:"confirm_password"` + CodeOTP *string `json:"code_otp"` + Key *string `json:"-"` +} + +// ValidatePassword validate passwords for change password +func (c *ChangePassword) ValidatePassword() bool { + return strings.TrimSpace(*c.Password) == strings.TrimSpace(*c.ConfirmPassword) +} + +// ComparePasswords compare user password and payload +func (l *Login) ComparePasswords(passw, key *string) error { + if err := bcrypt.CompareHashAndPassword([]byte(*passw), []byte(*key+*l.Password)); err != nil { + return ErrEmailOrPasswordIsNotValid() + } + l.SanitizePassword() + return nil +} + +// SanitizePassword sanitize user password +func (l *Login) SanitizePassword() { + l.Password = nil +} + +// Validate prepare data for login +func (l *Login) Validate() { + if l.ClientIP != nil && *l.ClientIP == "" { + l.ClientIP = utils.Pointer("0.0.0.0") + } + + if l.UserAgent != nil && *l.UserAgent == "" { + l.UserAgent = utils.Pointer("Unknown") + } +} + +// Session models the data of a user session +type Session struct { + SessionID *uuid.UUID `json:"session_id,omitempty"` + UserID *uuid.UUID `json:"user_id,omitempty"` + Email *string `json:"email,omitempty"` + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + Level *Level `json:"level,omitempty"` + Token *string `json:"token,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} diff --git a/domain/authentication/service.go b/domain/authentication/service.go new file mode 100644 index 0000000..159d0ee --- /dev/null +++ b/domain/authentication/service.go @@ -0,0 +1,53 @@ +package authentication + +import ( + "encoding/base32" + + "github.com/google/uuid" + "github.com/isaqueveras/powersso/config" + "github.com/isaqueveras/powersso/utils" +) + +// Service structure with repositories +type Service struct { + repoFlag IFlag + repoOTP IOTP +} + +// NewAuthService init new service +func NewAuthService(repoFlag IFlag, repoOTP IOTP) IAuthService { + return &Service{repoFlag: repoFlag, repoOTP: repoOTP} +} + +// Configure2FA add the flags to the configured 2fa user and generates the 2fa token +func (s *Service) Configure2FA(userID *uuid.UUID) (err error) { + if err = s.repoFlag.Set(userID, FlagOTPSetup); err != nil { + return err + } + + if err = s.repoFlag.Set(userID, FlagOTPEnable); err != nil { + return err + } + + data := []byte(utils.RandomString(26)) + dst := make([]byte, base32.StdEncoding.EncodedLen(len(data))) + base32.StdEncoding.Encode(dst, data) + return s.repoOTP.SetToken(userID, utils.Pointer(string(dst))) +} + +// GenerateQrCode2FA return the formatted url to configure 2-factor authentication +func (s *Service) GenerateQrCode2FA(userID *uuid.UUID) (url *string, err error) { + userName, token, err := s.repoOTP.GetToken(userID) + if err != nil { + return nil, err + } + + if config.Get().Server.IsModeDevelopment() { + *userName += " [DEV]" + } + + projectName := utils.Pointer(config.Get().ProjectName) + link := utils.GetUrlQrCode(projectName, token, userName) + + return utils.Pointer(link), nil +} diff --git a/infrastructure/persistencie/authentication/postgres/data_test.go b/infrastructure/persistencie/authentication/postgres/data_test.go new file mode 100644 index 0000000..f5fb43f --- /dev/null +++ b/infrastructure/persistencie/authentication/postgres/data_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2022 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package postgres + +import ( + "context" + "log" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/stretchr/testify/suite" + + pg "github.com/isaqueveras/powersso/database/postgres" + "github.com/isaqueveras/powersso/domain/authentication" + "github.com/isaqueveras/powersso/oops" + "github.com/isaqueveras/powersso/utils" +) + +func TestAuthInfrastructure(t *testing.T) { + suite.Run(t, new(authSuite)) +} + +type authSuite struct { + pg *PGAuth + mock sqlmock.Sqlmock + ctx context.Context + + suite.Suite +} + +func (a *authSuite) SetupTest() { + a.pg = new(PGAuth) + a.ctx = context.Background() + + var err error + if a.mock, err = pg.OpenConnectionsForTests(); err != nil { + a.Assert().FailNow(err.Error()) + } +} + +func (a *authSuite) SetupSuite() { + log.SetFlags(log.LstdFlags | log.Lshortfile) +} + +func (a *authSuite) TearDownTest() { + pg.CloseConnections() +} + +func (a *authSuite) TestShouldCreateUser() { + var ( + err error + userID *uuid.UUID + input = &authentication.CreateAccount{ + FirstName: utils.Pointer("Ayrton"), + LastName: utils.Pointer("Senna"), + Email: utils.Pointer("ayrton.senna@powersso.io"), + Password: utils.Pointer("$2a$12$7scJnkljH5misH./.qM0YeZi7sFEU4nu4fHqOtMqHbi/p5MmzIxpG"), + } + ) + + a.mock.ExpectBegin() + a.mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO user (first_name,last_name,email,password) VALUES ($1,$2,$3,$4) RETURNING "id"`)). + WithArgs(input.FirstName, input.LastName, input.Email, input.Password). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("9f4a65cf-099b-4ea6-b091-36a9c06ecc74")) + + a.pg.DB, err = pg.NewTransaction(a.ctx, false) + a.Require().NotNil(a.pg.DB) + a.Require().Nil(err, oops.Err(err)) + + userID, err = a.pg.CreateAccount(input) + a.Require().NotNil(userID) + a.Require().Nil(err, oops.Err(err)) +} diff --git a/infrastructure/persistencie/authentication/repository.go b/infrastructure/persistencie/authentication/repository.go new file mode 100644 index 0000000..5dcac08 --- /dev/null +++ b/infrastructure/persistencie/authentication/repository.go @@ -0,0 +1,130 @@ +// Copyright (c) 2023 Isaque Veras +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package auth + +import ( + "github.com/google/uuid" + database "github.com/isaqueveras/powersso/database/postgres" + domain "github.com/isaqueveras/powersso/domain/authentication" + infra "github.com/isaqueveras/powersso/infrastructure/persistencie/authentication/postgres" +) + +var _ domain.IAuth = (*repoAuth)(nil) + +type repoAuth struct { + pg *infra.PGAuth +} + +// NewAuthRepository creates a new repository +func NewAuthRepository(tx *database.Transaction) domain.IAuth { + return &repoAuth{pg: &infra.PGAuth{DB: tx}} +} + +// CreateAccount contains the flow for the user register in database +func (r *repoAuth) CreateAccount(data *domain.CreateAccount) (userID *uuid.UUID, err error) { + return r.pg.CreateAccount(data) +} + +// AddAttempts contains the flow for the add number failed attempts +func (r *repoAuth) AddAttempts(userID *uuid.UUID) error { + return r.pg.AddAttempts(userID) +} + +// LoginSteps contains the flow to get the data needed to retrieve the steps required to log in a user +func (r *repoAuth) LoginSteps(email *string) (*domain.Steps, error) { + return r.pg.LoginSteps(email) +} + +var _ domain.IOTP = (*repoOTP)(nil) + +type repoOTP struct{ pg *infra.OTP } + +// NewOTPRepo creates a new repository +func NewOTPRepo(tx *database.Transaction) domain.IOTP { + return &repoOTP{pg: &infra.OTP{DB: tx}} +} + +func (r *repoOTP) GetToken(userID *uuid.UUID) (*string, *string, error) { + return r.pg.GetToken(userID) +} + +func (r *repoOTP) SetToken(userID *uuid.UUID, secret *string) error { + return r.pg.SetToken(userID, secret) +} + +var _ domain.IFlag = (*repoFlag)(nil) + +type repoFlag struct { + pg *infra.Flag +} + +// NewFlagRepo creates a new repository +func NewFlagRepo(tx *database.Transaction) domain.IFlag { + return &repoFlag{pg: &infra.Flag{DB: tx}} +} + +func (r *repoFlag) Set(userID *uuid.UUID, flag domain.Flag) error { + return r.pg.Set(userID, flag) +} + +func (r *repoFlag) Get(userID *uuid.UUID) (*int64, error) { + return r.pg.Get(userID) +} + +var _ domain.ISession = (*repoSession)(nil) + +type repoSession struct { + pg *infra.Session +} + +// NewSessionRepository creates a new repository +func NewSessionRepository(tx *database.Transaction) domain.ISession { + return &repoSession{pg: &infra.Session{DB: tx}} +} + +// Create create a new session for a user +func (r *repoSession) Create(userID *uuid.UUID, clientIP, userAgent *string) (*uuid.UUID, error) { + return r.pg.Create(userID, clientIP, userAgent) +} + +// Delete delete a session for a user +func (r *repoSession) Delete(ids ...*uuid.UUID) error { + return r.pg.Delete(ids...) +} + +func (r *repoSession) Get(userID *uuid.UUID) ([]*uuid.UUID, error) { + return r.pg.Get(userID) +} + +var _ domain.IUser = (*repoUser)(nil) + +type repoUser struct { + pg *infra.User +} + +// NewUserRepository creates a new repository +func NewUserRepository(tx *database.Transaction) domain.IUser { + return &repoUser{pg: &infra.User{DB: tx}} +} + +// GetUser manages the flow for a user's data +func (r *repoUser) GetUser(user *domain.User) error { + return r.pg.GetUser(user) +} + +// AccountExists manages the flow to check if a user with the same identifier already exists +func (r *repoUser) AccountExists(email *string) error { + return r.pg.AccountExists(email) +} + +// DisableUser manages the flow to deactivate a user's account +func (r *repoUser) DisableUser(userUUID *uuid.UUID) error { + return r.pg.Disable(userUUID) +} + +// ChangePassword manages the flow to change a user's password +func (r *repoUser) ChangePassword(in *domain.ChangePassword) error { + return r.pg.ChangePassword(in) +}