Skip to content

Commit

Permalink
feat: create user useCase with password encryption
Browse files Browse the repository at this point in the history
  • Loading branch information
Quinntas committed Apr 29, 2024
1 parent f46c1af commit 5a3e187
Show file tree
Hide file tree
Showing 14 changed files with 238 additions and 9 deletions.
29 changes: 29 additions & 0 deletions api/user/domain/user.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package userDomain

import (
"context"

sharedDomain "github.com/quinntas/go-rest-template/api/shared/domain"
userValueObjects "github.com/quinntas/go-rest-template/api/user/domain/valueObjects"
"github.com/quinntas/go-rest-template/internal/api/types"
Expand All @@ -13,6 +15,33 @@ type UserDomain struct {
RoleId types.ID
}

func NewUserDomainWithValidation(
email string,
password string,
roleId int,
ctx context.Context,
) (UserDomain, error) {
emailValidated, err := userValueObjects.NewEmailWithValidation(email)
if err != nil {
return UserDomain{}, err
}

passwordValidated, err := userValueObjects.NewPasswordWithValidation(password, ctx)
if err != nil {
return UserDomain{}, err
}

return NewUserDomain(
types.ID{},
types.NewUUIDV4(),
types.Date{},
types.Date{},
emailValidated,
passwordValidated,
types.NewID(roleId),
), nil
}

func NewUserDomain(
id types.ID,
pid types.UUID,
Expand Down
2 changes: 1 addition & 1 deletion api/user/domain/valueObjects/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func NewEmail(value string) Email {
}

func NewEmailWithValidation(value string) (Email, error) {
err := guard.AgainstBadRegex("email", value, `^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
err := guard.AgainstBadEmail("email", value)
if err != nil {
return Email{}, err
}
Expand Down
22 changes: 18 additions & 4 deletions api/user/domain/valueObjects/password.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package userValueObjects

import "github.com/quinntas/go-rest-template/internal/api/utils/guard"
import (
"context"

"github.com/quinntas/go-rest-template/internal/api/encryption"
"github.com/quinntas/go-rest-template/internal/api/utils/env"
"github.com/quinntas/go-rest-template/internal/api/utils/guard"
"github.com/quinntas/go-rest-template/internal/api/web"
)

const (
maxUserPasswordLength = 50
Expand All @@ -17,10 +24,17 @@ func NewPassword(value string) Password {
}
}

func NewPasswordWithValidation(value string) (Password, error) {
err := guard.AgainstBetween("Password", value, minUserPasswordLength, minUserPasswordLength)
func NewPasswordWithValidation(value string, ctx context.Context) (Password, error) {
err := guard.AgainstBetween("Password", value, minUserPasswordLength, maxUserPasswordLength)
if err != nil {
return Password{}, err
}
return Password{Value: value}, nil
encryptedPassword, err := encryption.GenerateDefaultEncryption(
value,
env.GetEnvVariablesFromContext(ctx).Pepper,
)
if err != nil {
return Password{}, web.InternalError()
}
return Password{Value: encryptedPassword}, nil
}
15 changes: 14 additions & 1 deletion api/user/infra/database/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,18 @@ package userDatabase
import "github.com/quinntas/go-rest-template/internal/api/utils"

const (
TABLE_NAME utils.Key = "Users"
TABLE_NAME utils.Key = "Users"
TABLE_SCHEMA utils.Key = `
CREATE TABLE IF NOT EXISTS Users (
id INT AUTO_INCREMENT NOT NULL,
pid VARCHAR(191) NOT NULL,
email VARCHAR(191) NOT NULL,
password VARCHAR(191) NOT NULL,
roleId INT NOT NULL,
createdAt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY (pid),
UNIQUE KEY (email)
);`
)
3 changes: 2 additions & 1 deletion api/user/repo/userRepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import (
func Create(user userDomain.UserDomain, ctx context.Context) (sql.Result, error) {
db := database.GetDBClientFromContext(ctx)

return db.Exec(fmt.Sprintf("INSERT INTO %s (pid, email, password) VALUES (?, ?, ?)", userDatabase.TABLE_NAME),
return db.Exec(fmt.Sprintf("INSERT INTO %s (pid, email, password, roleId) VALUES (?, ?, ?, ?)", userDatabase.TABLE_NAME),
user.PID.Value,
user.Email.Value,
user.Password.Value,
user.RoleId.Value,
)
}

Expand Down
2 changes: 1 addition & 1 deletion api/user/useCases/createUser/createUserConstants.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package createUser

const (
DefaultRoleID = 1
USER_DEFAULT_ROLE_ID = 1
)
15 changes: 15 additions & 0 deletions api/user/useCases/createUser/createUserDTO.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
package createUser

import (
"context"

"github.com/quinntas/go-rest-template/internal/api/web"
)

type DTO struct {
Email string `json:"email"`
Password string `json:"password"`
}

func NewDTO(ctx context.Context) DTO {
json := ctx.Value(web.JSON_CTX_KEY).(map[string]interface{})

return DTO{
Email: json["email"].(string),
Password: json["password"].(string),
}
}
24 changes: 24 additions & 0 deletions api/user/useCases/createUser/createUserUseCase.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,37 @@ import (
"context"
"net/http"

userDomain "github.com/quinntas/go-rest-template/api/user/domain"
userRepo "github.com/quinntas/go-rest-template/api/user/repo"
"github.com/quinntas/go-rest-template/internal/api/utils"
"github.com/quinntas/go-rest-template/internal/api/web"
)

func UseCase(request *http.Request, response http.ResponseWriter, ctx context.Context) error {
dto := NewDTO(ctx)

user, err := userDomain.NewUserDomainWithValidation(
dto.Email,
dto.Password,
USER_DEFAULT_ROLE_ID,
ctx,
)
if err != nil {
return err
}

_, err = userRepo.Create(user, ctx)
if err != nil {
return web.NewHttpError(
http.StatusConflict,
"Email already exists",
utils.Map[interface{}]{},
)
}

web.JsonResponse(response, http.StatusCreated, &utils.Map[string]{
"message": "user created",
})

return nil
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ require (
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.5.1
golang.org/x/crypto v0.22.0
)

require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
golang.org/x/sys v0.19.0 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
97 changes: 97 additions & 0 deletions internal/api/encryption/encryption.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package encryption

import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"strconv"
"strings"

"golang.org/x/crypto/pbkdf2"
"golang.org/x/crypto/sha3"
)

type HashSalt struct {
Result string
}

func GenerateSalt(length uint32) []byte {
secret := make([]byte, length)
_, _ = rand.Read(secret)
return secret
}

type CryptoParams struct {
value string
Salt []byte
Pepper string
Iterations int
Length int
}

func ParseEncryption(value string, hash string, pepper string) (*CryptoParams, error) {
params := &CryptoParams{}

hashParams := strings.Split(hash, "$")

salt, err := hex.DecodeString(hashParams[1])
if err != nil {
return nil, err
}
iterations, err := strconv.Atoi(hashParams[2])
if err != nil {
return nil, err
}
length, err := strconv.Atoi(hashParams[3])
if err != nil {
return nil, err
}

params.Salt = salt
params.Iterations = iterations
params.Length = length
params.value = value
params.Pepper = pepper

return params, nil
}

func ConstantTimeStringCompare(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

func CompareEncryption(value string, hash string, pepper string) (bool, error) {
hashParams, err := ParseEncryption(value, hash, pepper)
if err != nil {
return false, err
}

newHash, err := GenerateEncryptionWithParams(hashParams)
if err != nil {
return false, err
}

return ConstantTimeStringCompare(hash, newHash), nil
}

func GenerateDefaultEncryption(value string, pepper string) (string, error) {
return GenerateEncryptionWithParams(&CryptoParams{
value: value,
Salt: GenerateSalt(32),
Pepper: pepper,
Iterations: 10000,
Length: 32,
})
}

func GenerateEncryptionWithParams(cryptoParams *CryptoParams) (string, error) {
hash := pbkdf2.Key([]byte(cryptoParams.Pepper+cryptoParams.value), cryptoParams.Salt, cryptoParams.Iterations, cryptoParams.Length, sha3.New256)

hashHex := hex.EncodeToString(hash)
saltHex := hex.EncodeToString(cryptoParams.Salt)

result := fmt.Sprintf("sha256$%s$%d$%d$%s", saltHex, cryptoParams.Iterations, cryptoParams.Length, hashHex)

return result, nil
}
7 changes: 7 additions & 0 deletions internal/api/utils/env/env.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package env

import (
"context"
"os"

"github.com/joho/godotenv"
Expand All @@ -18,6 +19,11 @@ type EnvVariables struct {
Port string
DatabaseURL string
RedisURL string
Pepper string
}

func GetEnvVariablesFromContext(ctx context.Context) EnvVariables {
return ctx.Value(ENV_CTX_KEY).(EnvVariables)
}

func NewEnvVariables() EnvVariables {
Expand All @@ -30,5 +36,6 @@ func NewEnvVariables() EnvVariables {
Port: getEnv("PORT", true),
DatabaseURL: getEnv("DATABASE_URL", true),
RedisURL: getEnv("REDIS_URL", true),
Pepper: getEnv("PEPPER", true),
}
}
17 changes: 16 additions & 1 deletion internal/api/utils/guard/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,27 @@ package guard
import (
"fmt"
"net/http"
"net/mail"
"regexp"

"github.com/quinntas/go-rest-template/internal/api/utils"
"github.com/quinntas/go-rest-template/internal/api/web"
)

func AgainstBadEmail(key string, value string) error {
_, err := mail.ParseAddress(value)
if err != nil {
return web.NewHttpError(
http.StatusUnprocessableEntity,
fmt.Sprintf("%s is not a valid email", key),
utils.Map[interface{}]{
"key": key,
},
)
}
return nil
}

func AgainstBadRegex(key string, value string, regex string) error {
compiledRegex := regexp.MustCompile(regex)
if result := compiledRegex.MatchString(value); !result {
Expand All @@ -26,7 +41,7 @@ func AgainstBadRegex(key string, value string, regex string) error {
func AgainstBetween(key string, value interface{}, min int, max int) error {
err := web.NewHttpError(
http.StatusUnprocessableEntity,
fmt.Sprintf("%s is not between %c and %c", key, min, max),
fmt.Sprintf("%s is not between %d and %d", key, min, max),
utils.Map[interface{}]{
"key": key,
},
Expand Down
8 changes: 8 additions & 0 deletions internal/api/web/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ func UnprocessableEntity() *HttpError {
)
}

func InternalError() *HttpError {
return NewHttpError(
http.StatusInternalServerError,
"internal server error",
utils.Map[interface{}]{},
)
}

func BadRequest() *HttpError {
return NewHttpError(
http.StatusBadRequest,
Expand Down

0 comments on commit 5a3e187

Please sign in to comment.