├── emails ├── accountlockemail │ ├── README.md │ ├── account-lock.html │ └── send.go ├── verifyemail │ ├── README.md │ ├── verify.html │ └── send.go ├── README.md ├── signinemail │ ├── README.md │ ├── send.go │ └── signin.html ├── sessionunlockemail │ ├── session-unlock-email.html │ └── send.go └── forgotpasswordemail │ ├── send.go │ └── forgot-password.html ├── health └── handlers.go ├── integrations └── sendgrid │ ├── README.md │ ├── env.go │ └── email.go ├── common ├── ids.go └── models.go ├── .gitignore ├── env └── env.go ├── db └── db.go ├── middleware └── cors.go ├── auth └── auth.go ├── user ├── forgotpassword_repository.go ├── README.md ├── repository.go ├── models.go ├── handlers.go └── services.go ├── logging ├── README.md └── logging.go ├── go.mod ├── cars ├── services.go ├── repository.go ├── models.go └── handlers.go ├── main.go ├── README.md └── go.sum /emails/accountlockemail/README.md: -------------------------------------------------------------------------------- 1 | # Account Lock 2 | -------------------------------------------------------------------------------- /emails/verifyemail/README.md: -------------------------------------------------------------------------------- 1 | # Verify 2 | 3 | It looks like this: 4 | 5 | TODO - Add screenshot -------------------------------------------------------------------------------- /health/handlers.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | func Check(c *gin.Context) { 8 | c.JSON(200, gin.H{"message": "Healthy"}) 9 | } 10 | -------------------------------------------------------------------------------- /emails/README.md: -------------------------------------------------------------------------------- 1 | # Emails 2 | 3 | ## How to view HTML 4 | 5 | TODO 6 | 7 | ## How to update HTML 8 | 9 | TODO - ie. Copy paste into Chrome 10 | 11 | ## How to get plain text 12 | 13 | TODO 14 | -------------------------------------------------------------------------------- /integrations/sendgrid/README.md: -------------------------------------------------------------------------------- 1 | # Sendgrid 2 | 3 | TODO - About 4 | 5 | ## Installation 6 | 7 | ``` 8 | go get github.com/sendgrid/sendgrid-go 9 | ``` 10 | 11 | ## Setting Up API Key 12 | 13 | TODO -------------------------------------------------------------------------------- /emails/signinemail/README.md: -------------------------------------------------------------------------------- 1 | # Sign In Email 2 | 3 | It looks like this: 4 | 5 | TODO - Add screenshot 6 | 7 | You need to search and update: 8 | * `yourwebsite.com` 9 | * `Made by KeithWeaver` 10 | * General look and feel (this is a starting point) -------------------------------------------------------------------------------- /common/ids.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "github.com/google/uuid" 6 | "strings" 7 | ) 8 | 9 | func CreateID(prefix string) string { 10 | str := uuid.New().String() 11 | str = strings.ReplaceAll(str, "-", "") 12 | return fmt.Sprintf("%s_%s", prefix, str) 13 | } -------------------------------------------------------------------------------- /integrations/sendgrid/env.go: -------------------------------------------------------------------------------- 1 | package sendgrid 2 | 3 | import "os" 4 | 5 | 6 | func GetSendgridAPIKey() string { 7 | return os.Getenv("SENDGRID_API_KEY") 8 | } 9 | 10 | func GetSenderEmail() string { 11 | return os.Getenv("SENDER_EMAIL") 12 | } 13 | 14 | func GetSenderName() string { 15 | return os.Getenv("SENDER_NAME") 16 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | .DS_Store 17 | TODO.md 18 | logs.txt 19 | .idea/ 20 | secret.md -------------------------------------------------------------------------------- /env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | // Environment variables: 9 | // - SENDGRID_API_KEY 10 | // - SENDER_EMAIL 11 | // - SENDER_NAME 12 | // - DB_NAME 13 | // - FRONTEND_DOMAIN 14 | 15 | 16 | // VerifyRequiredEnvVarsSet checks that the minimum set of environment variables 17 | // are set. If you remove this method from the main.go, there may be unexpected 18 | // issues. 19 | func VerifyRequiredEnvVarsSet() { 20 | if os.Getenv("DB_NAME") == "" { 21 | // TODO - Add note to documentation 22 | log.Fatal("Error: DB_NAME is required in environment variables") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /common/models.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | type Error struct { 6 | StatusCode int `json:"statusCode"` 7 | Message string `json:"message"` 8 | } 9 | 10 | func ReturnErrorResponse(c *gin.Context, err *Error) { 11 | if err == nil { 12 | c.JSON(500, gin.H{ 13 | "message": "Error: Internal server error", 14 | }) 15 | return 16 | } 17 | message := err.Message 18 | if err.StatusCode == 403 && message == ""{ 19 | message = "Error: Unauthorized" 20 | } else if err.StatusCode == 400 && message == ""{ 21 | message = "Error: Invalid payload" 22 | } else if err.StatusCode == 500 && message == "" { 23 | message = "Error: Internal server error" 24 | } 25 | c.JSON(err.StatusCode, gin.H{ 26 | "message": message, 27 | }) 28 | } -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | // "database/sql" 8 | // _ "github.com/go-sql-driver/mysql" 9 | // "github.com/jmoiron/sqlx" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | "go.mongodb.org/mongo-driver/mongo/options" 12 | ) 13 | 14 | // Use this for connect to Mongo. 15 | func CreateDatabaseConnection(dbName string) (*mongo.Client, error) { 16 | clientOptions := options.Client().ApplyURI("mongodb://localhost:27017") 17 | 18 | // Connect to MongoDB 19 | client, err := mongo.Connect(context.TODO(), clientOptions) 20 | 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | // Check the connection 26 | err = client.Ping(context.TODO(), nil) 27 | 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | fmt.Println("Connected to MongoDB!") 33 | return client, err 34 | } 35 | -------------------------------------------------------------------------------- /middleware/cors.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "os" 6 | ) 7 | 8 | func CORSMiddleware() gin.HandlerFunc { 9 | frontendDomain := os.Getenv("FRONTEND_DOMAIN") 10 | return func(c *gin.Context) { 11 | if frontendDomain != "" { 12 | c.Writer.Header().Set("Access-Control-Allow-Origin", frontendDomain) 13 | } 14 | c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") 15 | c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") 16 | c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") 17 | 18 | if c.Request.Method == "OPTIONS" { 19 | c.AbortWithStatus(204) 20 | return 21 | } 22 | 23 | c.Next() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gin-gonic/gin" 6 | "go-boilerplate/user" 7 | "strings" 8 | ) 9 | 10 | func ValidateAuth(userRepository user.Repository) gin.HandlerFunc { 11 | return func(c *gin.Context) { 12 | authToken := c.Request.Header.Get("Authorization") 13 | if authToken == "" { 14 | c.AbortWithStatus(403) 15 | return 16 | } 17 | 18 | authToken = strings.ReplaceAll(authToken, "Bearer ", "") 19 | 20 | found, session, err := userRepository.GetSessionById(authToken) 21 | if err != nil { 22 | fmt.Printf("err :: %+v\n", err) 23 | c.AbortWithStatus(403) 24 | return 25 | } 26 | if !found { 27 | fmt.Println("Not found") 28 | c.AbortWithStatus(403) 29 | return 30 | } 31 | if session.Locked { 32 | fmt.Println("Session locked") 33 | c.AbortWithStatus(403) 34 | return 35 | } 36 | c.Set("session", session) 37 | 38 | c.Next() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /integrations/sendgrid/email.go: -------------------------------------------------------------------------------- 1 | package sendgrid 2 | 3 | import ( 4 | "errors" 5 | "github.com/sendgrid/sendgrid-go" 6 | "github.com/sendgrid/sendgrid-go/helpers/mail" 7 | ) 8 | 9 | // SendEmail sends an email using the Sendgrid SDK. If this returns an error, you should return a 500 on 10 | // the service & handler level. 11 | func SendEmail(fullName string, email string, subject string, plainTextContent string, htmlContent string) error { 12 | apiKey := GetSendgridAPIKey() 13 | if apiKey == "" { 14 | return errors.New("error: no Sendgrid API key found in environment variables") 15 | } 16 | senderEmail := GetSenderEmail() 17 | if senderEmail == "" { 18 | return errors.New("error: no sender email found in environment variables") 19 | } 20 | senderName := GetSenderName() 21 | if senderName == "" { 22 | return errors.New("error: no sender name found in environment variables") 23 | } 24 | 25 | from := mail.NewEmail(senderName, senderEmail) 26 | to := mail.NewEmail(fullName, email) 27 | message := mail.NewSingleEmail(from, subject, to, plainTextContent, htmlContent) 28 | client := sendgrid.NewSendClient(apiKey) 29 | _, err := client.Send(message) 30 | return err 31 | } 32 | -------------------------------------------------------------------------------- /user/forgotpassword_repository.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "go.mongodb.org/mongo-driver/bson" 6 | "go.mongodb.org/mongo-driver/mongo" 7 | "time" 8 | ) 9 | 10 | type ForgotPasswordRepository struct { 11 | db *mongo.Database 12 | forgotPasswordCollection string 13 | } 14 | 15 | func NewInstanceOfForgotPasswordRepository(db *mongo.Database) ForgotPasswordRepository { 16 | return ForgotPasswordRepository{db: db, forgotPasswordCollection: "forgotPasswordCodes"} 17 | } 18 | 19 | func (r *ForgotPasswordRepository) Save(forgotPassword ForgotPasswordCode) error { 20 | _, err := r.db.Collection(r.forgotPasswordCollection).InsertOne(context.TODO(), forgotPassword) 21 | if err != nil { 22 | return err 23 | } 24 | return nil 25 | } 26 | 27 | func (r *ForgotPasswordRepository) Exists(email string, code string) (bool, error) { 28 | filter := bson.M{"email": email, "code": code} 29 | count, err := r.db.Collection(r.forgotPasswordCollection).CountDocuments(context.TODO(), filter) 30 | if err != nil { 31 | return false, err 32 | } 33 | return count > 0, nil 34 | } 35 | 36 | func (r *ForgotPasswordRepository) MarkCodeAsComplete(email string, code string) error { 37 | filter := bson.M{"email": email, "code": code} 38 | update := bson.M{"$set": bson.M{"expiry": time.Now()}} 39 | _, err := r.db.Collection(r.forgotPasswordCollection).UpdateOne(context.TODO(), filter, update) 40 | if err != nil { 41 | return err 42 | } 43 | return nil 44 | } -------------------------------------------------------------------------------- /user/README.md: -------------------------------------------------------------------------------- 1 | # User 2 | 3 | ## Sign In 4 | 5 | Features: 6 | * Email on sign in 7 | * Verify 8 | * Tracks user agent 9 | * Tracks general location 10 | 11 | ### Verification 12 | 13 | TODO - Approach (One table or two) 14 | TODO - User Agent can be altered so it's a nice to have 15 | 16 | ## Walk through of Sign In & Sign Up Flow 17 | 18 | This will be a walk through of how the sign in and sign up flow works. The simplest approach to accessing an account is using SSO. 19 | 20 | The basic sign up, you will need an API key from Sendgrid. Run your API with: 21 | 22 | ``` 23 | SENDGRID_API_KEY= SENDER_EMAIL= SENDER_NAME="Your Name" go run main.go 24 | ``` 25 | 26 | When API is running, let's start with the sign up. 27 | 28 | ```bash 29 | curl --location --request POST 'http://localhost:8080/user/signup/' \ 30 | --header 'Content-Type: application/json' \ 31 | --data-raw '{ 32 | "email": "me@keithweaver.ca", 33 | "password": "demodemo1" 34 | }' 35 | ``` 36 | 37 | The response is: 38 | 39 | ``` 40 | { 41 | "message": "error: Your password does not meet requirements." 42 | } 43 | ``` 44 | 45 | That's a pretty generic response. Yes, it is, but you would have the password checking on the frontend. You can see the log: 46 | 47 | ``` 48 | WARNING: 2021/03/15 07:24:59 logging.go:67: {"message": "password is has less than 5 special characters", "error": "error: invalid password", "requestId": "a09bd215-15d0-46fc-8d83-ad3579612f25", "domain": "user", "handlerMethod": "SignUp", "serviceMethod": "SignUp", "clientIP": "::1"} 49 | ``` -------------------------------------------------------------------------------- /logging/README.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | The severity levels are fairly self explanatory: 4 | - Info: Nice to know 5 | - Warning: Error occurred but not mission critical 6 | - Error: Mission critical error 7 | 8 | ## Context 9 | We declare an initial context on each request, and add values to that context. This context will be passed through the stack trace. The most common use case will be the logger. This provides better reporting for where and how issues happened. Each instance of services, handler, repository, etc. should have a logger declared. 10 | 11 | The handler will look like: 12 | 13 | ``` 14 | ctx := context.Background() 15 | ctx = context.WithValue(ctx, logging.CtxDomain, "Cars") 16 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "GetAll") 17 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 18 | 19 | u.logger.Info(ctx, "Called") 20 | ``` 21 | 22 | This ctx will be the first argument as we call further down the stack (ie. The Service layer). The info log above has very little details, but it is backfilled by the context and would output: 23 | 24 | ``` 25 | INFO: 2021/03/11 07:52:54 logging.go:64: {"message": "Called", "requestId": "43cc7b70-f7e6-4646-a8d0-ec7e4af9a251", "domain": "Cars", "handlerMethod": "GetAll"} 26 | ``` 27 | 28 | The service layer would be: 29 | ``` 30 | func (c *CarsService) GetAll(ctx context.Context, session models.Session, query models.ListCarQuery) ([]models.Car, error) { 31 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "GetAll") 32 | 33 | ... 34 | return cars, nil 35 | } 36 | ``` 37 | 38 | Once again, we simply build onto the context. Understanding this context is only scope to this service. If we log on the next level up, it will not include the service layer information. 39 | 40 | -------------------------------------------------------------------------------- /emails/sessionunlockemail/session-unlock-email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | 7 |

Confirm Your Sign In

8 |
9 |
10 |

11 | Hi there,
12 |
13 | We noticed a new sign in for your account. We want to just confirm it is you, please copy the code below into the input box on screen: 14 |

15 | 16 |
17 | 18 | 30190fcb-d10d-4b88-bacd-966907de0e2e 19 | 20 |
21 |
22 |
23 |

Made by KeithWeaver

24 |

25 | 26 | Our Blog 27 | 28 | 29 | Our Privacy Policy 30 | 31 |

32 |

33 |
34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /emails/sessionunlockemail/send.go: -------------------------------------------------------------------------------- 1 | package sessionunlockemail 2 | 3 | import "go-boilerplate/integrations/sendgrid" 4 | 5 | func SendSessionUnLockEmail(fullName string, email string, code string) error { 6 | plainTextContent := "Hi " + fullName + ",\n\nWe noticed a new sign in for your account. We want to just confirm it is you, please copy the code below into the input box on screen: " + code + "\n\nFeel free to reach out to our support team (support@yourwebsite.com) with any questions." 7 | htmlContent := "

Confirm Your Sign In

Hi " + fullName + ",

We noticed a new sign in for your account. We want to just confirm it is you, please copy the code below into the input box on screen:

" + code + "

Made by KeithWeaver

Our Blog Our Privacy Policy

" 8 | return sendgrid.SendEmail(fullName, email, "Suspicious Activity on Your Account", plainTextContent, htmlContent) 9 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go-boilerplate 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.9.1 7 | github.com/go-playground/validator/v10 v10.14.0 8 | github.com/go-sql-driver/mysql v1.5.0 9 | github.com/google/uuid v1.1.1 10 | github.com/mssola/user_agent v0.5.2 11 | github.com/sendgrid/sendgrid-go v3.8.0+incompatible 12 | go.mongodb.org/mongo-driver v1.5.1 13 | golang.org/x/crypto v0.17.0 14 | ) 15 | 16 | require ( 17 | github.com/aws/aws-sdk-go v1.34.28 // indirect 18 | github.com/bytedance/sonic v1.9.1 // indirect 19 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 20 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 21 | github.com/gin-contrib/sse v0.1.0 // indirect 22 | github.com/go-playground/locales v0.14.1 // indirect 23 | github.com/go-playground/universal-translator v0.18.1 // indirect 24 | github.com/go-stack/stack v1.8.0 // indirect 25 | github.com/goccy/go-json v0.10.2 // indirect 26 | github.com/golang/snappy v0.0.1 // indirect 27 | github.com/jmespath/go-jmespath v0.4.0 // indirect 28 | github.com/json-iterator/go v1.1.12 // indirect 29 | github.com/klauspost/compress v1.9.5 // indirect 30 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 31 | github.com/leodido/go-urn v1.2.4 // indirect 32 | github.com/mattn/go-isatty v0.0.19 // indirect 33 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 34 | github.com/modern-go/reflect2 v1.0.2 // indirect 35 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 36 | github.com/pkg/errors v0.9.1 // indirect 37 | github.com/sendgrid/rest v2.6.2+incompatible // indirect 38 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 39 | github.com/ugorji/go/codec v1.2.11 // indirect 40 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 41 | github.com/xdg-go/scram v1.0.2 // indirect 42 | github.com/xdg-go/stringprep v1.0.2 // indirect 43 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect 44 | golang.org/x/arch v0.3.0 // indirect 45 | golang.org/x/net v0.17.0 // indirect 46 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e // indirect 47 | golang.org/x/sys v0.15.0 // indirect 48 | golang.org/x/text v0.14.0 // indirect 49 | google.golang.org/protobuf v1.30.0 // indirect 50 | gopkg.in/yaml.v3 v3.0.1 // indirect 51 | ) 52 | -------------------------------------------------------------------------------- /cars/services.go: -------------------------------------------------------------------------------- 1 | package cars 2 | 3 | import ( 4 | "context" 5 | "go-boilerplate/logging" 6 | "go-boilerplate/user" 7 | 8 | // "fmt" 9 | "time" 10 | // "common" 11 | // "github.com/google/uuid" 12 | ) 13 | 14 | type Services struct { 15 | logger logging.Logger 16 | userRepository user.Repository 17 | carsRepository Repository 18 | } 19 | 20 | func NewInstanceOfCarsServices(logger logging.Logger, userRepository user.Repository, carsRepository Repository) Services { 21 | return Services{logger, userRepository, carsRepository} 22 | } 23 | 24 | func (c *Services) GetAll(ctx context.Context, session user.Session, query ListCarQuery) ([]Car, error) { 25 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "GetAll") 26 | 27 | cars, err := c.carsRepository.List(session.Email, query) 28 | if err != nil { 29 | return []Car{}, err 30 | } 31 | return cars, nil 32 | } 33 | 34 | func (c *Services) GetByID(ctx context.Context, session user.Session, carID string) (Car, error) { 35 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "GetByID") 36 | 37 | car, err := c.carsRepository.Get(session.Email, carID) 38 | if err != nil { 39 | return Car{}, err 40 | } 41 | return car, nil 42 | } 43 | 44 | func (c *Services) Create(ctx context.Context, session user.Session, body CreateCar) error { 45 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "Create") 46 | // Create new car object 47 | car := Car{ 48 | Make: body.Make, 49 | Model: body.Model, 50 | Year: body.Year, 51 | Status: "", 52 | Created: time.Now(), 53 | Email: session.Email, 54 | } 55 | err := c.carsRepository.Save(car) 56 | if err != nil { 57 | return err 58 | } 59 | return nil 60 | } 61 | 62 | func (c *Services) Update(ctx context.Context, session user.Session, carID string, body UpdateCar) error { 63 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "Update") 64 | // Update car 65 | err := c.carsRepository.Update(session.Email, carID, body) 66 | if err != nil { 67 | return err 68 | } 69 | return nil 70 | } 71 | 72 | func (c *Services) Delete(ctx context.Context, session user.Session, carID string) error { 73 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "Delete") 74 | 75 | // Delete car 76 | err := c.carsRepository.Delete(session.Email, carID) 77 | if err != nil { 78 | return err 79 | } 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /emails/accountlockemail/account-lock.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | 7 |

Suspicious Activity on Your Account

8 |
9 |
10 |

11 | Hi there,
12 |
13 | We have detected suspicious activity on your account. We have locked your account as a precautionary. You can reaccess your account using forgot password. 14 |

15 | 16 | 17 |

18 | If this was continues to happen, please reach out to our support team. 19 |

20 |
21 |
22 |

Made by KeithWeaver

23 |

24 | 25 | Our Blog 26 | 27 | 28 | Our Privacy Policy 29 | 30 |

31 |

32 |
33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /emails/forgotpasswordemail/send.go: -------------------------------------------------------------------------------- 1 | package forgotpasswordemail 2 | 3 | import "go-boilerplate/integrations/sendgrid" 4 | 5 | func SendForgotPasswordEmail(fullName string, email string, code string) error { 6 | plainTextContent := "Hi " + fullName + ",\n\nHi there,\n\nYou have request to reset your password. Please use the follow link: https://yourwebsite.com/forgot-passowrd/send?code=" + code 7 | htmlContent := "

Reset Your Password

Hi " + fullName + ",

You have request to reset your password. Please use the button below to enter a new password:

Link expire? Request a new one.

Made by KeithWeaver

Our Blog Our Privacy Policy

" 8 | return sendgrid.SendEmail(fullName, email, "Forgot Password", plainTextContent, htmlContent) 9 | } -------------------------------------------------------------------------------- /emails/forgotpasswordemail/forgot-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | 7 |

Reset Your Password

8 |
9 |
10 |

11 | Hi there,
12 |
13 | You have request to reset your password. Please use the button below to enter a new password: 14 |

15 | 16 |
17 | Reset Password 18 |
19 | 20 |

21 | Link expire? Request a new one. 22 |

23 |
24 |
25 |

Made by KeithWeaver

26 |

27 | 28 | Our Blog 29 | 30 | 31 | Our Privacy Policy 32 | 33 |

34 |

35 |
36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /emails/accountlockemail/send.go: -------------------------------------------------------------------------------- 1 | package accountlockemail 2 | 3 | import "go-boilerplate/integrations/sendgrid" 4 | 5 | func SendAccountLockedEmail(fullName string, email string) error { 6 | plainTextContent := "Hi " + fullName + ",\n\nWe have detected suspicious activity on your account. We have locked your account as a precautionary. You can reaccess your account using forgot password (https://yourwebsite.com/forgot-password).\n\nIf this was continues to happen, please reach out to our support team (support@yourwebsite.com)." 7 | htmlContent := "

Suspicious Activity on Your Account

Hi " + fullName + ",

We have detected suspicious activity on your account. We have locked your account as a precautionary. You can reaccess your account using forgot password.

If this was continues to happen, please reach out to our support team.

Made by KeithWeaver

Our Blog Our Privacy Policy

" 8 | return sendgrid.SendEmail(fullName, email, "Suspicious Activity on Your Account", plainTextContent, htmlContent) 9 | } -------------------------------------------------------------------------------- /emails/verifyemail/verify.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | 7 |

Confirm Your Email

8 |
9 |
10 |

11 | Hi there,
12 |
13 | Welcome to my app! We are very excited to have you use it. Confirming your email is the final step in your sign up process. Please use the button below to confirm: 14 |

15 | 16 |
17 | Confirm Email 18 |
19 | 20 |

21 | Link expire? Request a new one. 22 |

23 |
24 |
25 |

Made by KeithWeaver

26 |

27 | 28 | Our Blog 29 | 30 | 31 | Our Privacy Policy 32 | 33 |

34 |

35 |
36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "go-boilerplate/auth" 7 | "go-boilerplate/cars" 8 | "go-boilerplate/env" 9 | "go-boilerplate/health" 10 | "go-boilerplate/logging" 11 | "go-boilerplate/middleware" 12 | "go-boilerplate/user" 13 | "os" 14 | 15 | //"strings" 16 | // "time" 17 | 18 | "github.com/gin-gonic/gin" 19 | "go-boilerplate/db" 20 | // "github.com/gin-contrib/cors" 21 | // "database/sql" 22 | _ "github.com/go-sql-driver/mysql" 23 | ) 24 | 25 | func main() { 26 | fmt.Println("Starting...") 27 | logger := logging.NewLogger() 28 | 29 | env.VerifyRequiredEnvVarsSet() 30 | 31 | dbName := os.Getenv("DB_NAME") 32 | client, err := db.CreateDatabaseConnection(dbName) 33 | if err != nil { 34 | fmt.Println("Failed to connect to DB") 35 | panic(err) 36 | } 37 | defer client.Disconnect(context.TODO()) 38 | 39 | db := client.Database(dbName) 40 | 41 | // Repositories 42 | userRepository := user.NewInstanceOfUserRepository(db) 43 | carsRepository := cars.NewInstanceOfCarsRepository(db) 44 | forgotPasswordRepository := user.NewInstanceOfForgotPasswordRepository(db) 45 | 46 | // Services 47 | userServices := user.NewInstanceOfUserServices(logger, userRepository, forgotPasswordRepository) 48 | carsServices := cars.NewInstanceOfCarsServices(logger, userRepository, carsRepository) 49 | 50 | // Handlers 51 | userHandlers := user.NewInstanceOfUserHandlers(logger, userServices) 52 | carsHandlers := cars.NewInstanceOfCarsHandlers(logger, carsServices) 53 | 54 | router := gin.Default() 55 | router.Use(middleware.CORSMiddleware()) 56 | 57 | healthAPI := router.Group("/") 58 | { 59 | healthAPI.GET("", health.Check) 60 | healthAPI.GET("health", health.Check) 61 | } 62 | 63 | userAPI := router.Group("/user") 64 | { 65 | userAPI.POST("/signin", userHandlers.SignIn) 66 | userAPI.POST("/signup", userHandlers.SignUp) 67 | userAPI.POST("/signout", auth.ValidateAuth(userRepository), userHandlers.LogOut) 68 | userAPI.POST("/session/unlock", userHandlers.UnlockSession) 69 | userAPI.POST("/forgot-password/", userHandlers.SendForgotPassword) 70 | userAPI.POST("/forgot-password/reset", userHandlers.ForgotPassword) 71 | } 72 | 73 | carsAPI := router.Group("/cars") 74 | { 75 | carsAPI.GET("/", auth.ValidateAuth(userRepository), carsHandlers.GetAll) 76 | carsAPI.GET("/:id", auth.ValidateAuth(userRepository), carsHandlers.GetByID) 77 | carsAPI.POST("/", auth.ValidateAuth(userRepository), carsHandlers.Create) 78 | carsAPI.PUT("/:id", auth.ValidateAuth(userRepository), carsHandlers.Update) 79 | carsAPI.DELETE("/:id", auth.ValidateAuth(userRepository), carsHandlers.Delete) 80 | } 81 | 82 | router.Run(":8080") 83 | } 84 | -------------------------------------------------------------------------------- /emails/verifyemail/send.go: -------------------------------------------------------------------------------- 1 | package verifyemail 2 | 3 | import "go-boilerplate/integrations/sendgrid" 4 | 5 | func SendVerifyEmail(fullName string, email string, validationCode string) error { 6 | plainTextContent := "Hi " + fullName + ",\n\nWelcome to my app! We are very excited to have you use it. Confirming your email is the final step in your sign up process. Please use this link to confirm: https://yourwebsite.com/complete-validation?code=" + validationCode + "&email=" + email + "\n\n Link expire? Request a new one here: https://yourwebsite.com/request-validation?email=" + email 7 | htmlContent := "

Confirm Your Email

Hi " + fullName + ",

Welcome to my app! We are very excited to have you use it. Confirming your email is the final step in your sign up process. Please use the button below to confirm:

Link expire? Request a new one.

Made by KeithWeaver

Our Blog Our Privacy Policy

" 8 | return sendgrid.SendEmail(fullName, email, "Confirm Email", plainTextContent, htmlContent) 9 | } 10 | -------------------------------------------------------------------------------- /cars/repository.go: -------------------------------------------------------------------------------- 1 | package cars 2 | 3 | import ( 4 | "context" 5 | 6 | // "database/sql" 7 | // "github.com/jmoiron/sqlx" 8 | "go.mongodb.org/mongo-driver/bson" 9 | "go.mongodb.org/mongo-driver/bson/primitive" 10 | "go.mongodb.org/mongo-driver/mongo" 11 | "go.mongodb.org/mongo-driver/mongo/options" 12 | ) 13 | 14 | type Repository struct { 15 | db *mongo.Database 16 | collectionName string 17 | } 18 | 19 | func NewInstanceOfCarsRepository(db *mongo.Database) Repository { 20 | return Repository{db: db, collectionName: "cars"} 21 | } 22 | 23 | func (c *Repository) List(email string, query ListCarQuery) ([]Car, error) { 24 | filters := query.Filter(email) 25 | var cars []Car 26 | 27 | options := options.Find() 28 | 29 | // Add paging 30 | options.SetLimit(int64(query.Limit)) 31 | options.SetSkip(int64((query.Page * query.Limit) - query.Limit)) 32 | 33 | // Add timestamp 34 | options.SetSort(bson.M{"created": -1}) 35 | 36 | cursor, err := c.db.Collection(c.collectionName).Find(context.Background(), filters, options) 37 | if err != nil { 38 | return []Car{}, err 39 | } 40 | 41 | for cursor.Next(context.Background()) { 42 | car := Car{} 43 | err := cursor.Decode(&car) 44 | if err != nil { 45 | //handle err 46 | } else { 47 | cars = append(cars, car) 48 | } 49 | } 50 | return cars, nil 51 | } 52 | 53 | func (c *Repository) Get(email string, carID string) (Car, error) { 54 | docID, err := primitive.ObjectIDFromHex(carID) 55 | if err != nil { 56 | return Car{}, err 57 | } 58 | 59 | filter := bson.M{"_id": docID, "email": email} 60 | 61 | var result Car 62 | err = c.db.Collection(c.collectionName).FindOne(context.TODO(), filter).Decode(&result) 63 | if err != nil { 64 | return Car{}, err 65 | } 66 | return result, nil 67 | } 68 | 69 | func (c *Repository) Save(car Car) error { 70 | _, err := c.db.Collection(c.collectionName).InsertOne(context.TODO(), car) 71 | if err != nil { 72 | return err 73 | } 74 | // insertResult.InsertedID.(string) 75 | return nil 76 | } 77 | 78 | func (c *Repository) Update(email string, carID string, body UpdateCar) error { 79 | docID, err := primitive.ObjectIDFromHex(carID) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | update := body.Update() 85 | if update == nil { 86 | return nil 87 | } 88 | filter := bson.M{"_id": docID, "email": email} 89 | 90 | _, err = c.db.Collection(c.collectionName).UpdateOne(context.TODO(), filter, update) 91 | if err != nil { 92 | return err 93 | } 94 | return nil 95 | } 96 | 97 | func (c *Repository) Delete(email string, carID string) error { 98 | docID, err := primitive.ObjectIDFromHex(carID) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | filter := bson.M{"_id": docID, "email": email} 104 | _, err = c.db.Collection(c.collectionName).DeleteOne(context.TODO(), filter) 105 | if err != nil { 106 | return err 107 | } 108 | return nil 109 | } 110 | -------------------------------------------------------------------------------- /emails/signinemail/send.go: -------------------------------------------------------------------------------- 1 | package signinemail 2 | 3 | import "go-boilerplate/integrations/sendgrid" 4 | 5 | func SendSignInEmail(fullName string, email string, ip string, browser string, operatingSystem string) error { 6 | plainTextContent := "Hi " + fullName + ",\n\nThere was a new sign into your account.\n\nIP: " + ip + "\nBrowser: " + browser + "\nOS: " + operatingSystem + "\n\nIf this was you, you’re all set!\n\nIf this wasn't you, please change your password. You can also enable two-factor authentication to help secure your account.\n\nMade by KeithWeaver.ca" 7 | htmlContent := "

New Device Signed Into Your App

Hi " + fullName + ",

There was a new sign into your account.

IP " + ip + "
Browser " + browser + "
OS " + operatingSystem + "

If this was you, you’re all set!

If this wasn not you, please change your password. You can also enable two-factor authentication to help secure your account.

Made by KeithWeaver

Our Blog Our Privacy Policy

" 8 | return sendgrid.SendEmail(fullName, email, "New Sign-In", plainTextContent, htmlContent) 9 | } -------------------------------------------------------------------------------- /logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | ) 10 | 11 | // Why do the set this way? There is no easy way to list keys. You can check for 12 | // them, so by listing all attributes as a constant and only adding the ones 13 | // that exist. 14 | // https://stackoverflow.com/questions/54926712/is-there-a-way-to-list-keys-in-context-context 15 | var CtxRequestID = "requestId" 16 | var CtxDomain = "domain" 17 | var CtxHandlerMethod = "handlerMethod" 18 | var CtxServiceMethod = "serviceMethod" 19 | var CtxHelpMethods = "helperMethods" 20 | var CtxEmail = "email" 21 | var CtxClientIP = "clientIP" 22 | var loggingAttributes = []string{ 23 | CtxRequestID, 24 | CtxDomain, 25 | CtxHandlerMethod, 26 | CtxServiceMethod, 27 | CtxEmail, 28 | CtxClientIP, 29 | CtxHelpMethods, 30 | } 31 | 32 | type Logger struct { 33 | infoLogger *log.Logger 34 | warningLogger *log.Logger 35 | errorLogger *log.Logger 36 | } 37 | 38 | func NewLogger() Logger { 39 | logPath := os.Getenv("LOG_PATH") 40 | if logPath == "" { 41 | logPath = "logs.txt" 42 | } 43 | 44 | file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | mw := io.MultiWriter(file, os.Stdout) 50 | 51 | infoLogger := log.New(mw, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile) 52 | warningLogger := log.New(mw, "WARNING: ", log.Ldate|log.Ltime|log.Lshortfile) 53 | errorLogger := log.New(mw, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile) 54 | 55 | return Logger{ 56 | infoLogger, 57 | warningLogger, 58 | errorLogger, 59 | } 60 | } 61 | 62 | func (l *Logger) Info(ctx context.Context, message string) { 63 | l.infoLogger.Println(l.buildPayload(ctx, message, "")) 64 | } 65 | 66 | func (l *Logger) Warning(ctx context.Context, message string, error error) { 67 | l.warningLogger.Println(l.buildPayload(ctx, message, error.Error())) 68 | } 69 | 70 | func (l *Logger) Error(ctx context.Context, message string, error error) { 71 | l.errorLogger.Println(l.buildPayload(ctx, message, error.Error())) 72 | } 73 | 74 | func (l *Logger) buildPayload(ctx context.Context, message string, error string) string { 75 | payload := fmt.Sprintf("\"message\": \"%s\"", message) 76 | if error != "" { 77 | payload += fmt.Sprintf(", \"error\": \"" + error + "\"") 78 | } 79 | for _, loggingAttribute := range loggingAttributes { 80 | value, ok := ctx.Value(loggingAttribute).(string) 81 | if ok { 82 | payload += fmt.Sprintf(", \"%s\": \"%s\"", loggingAttribute, value) 83 | } 84 | } 85 | return "{" + payload + "}" 86 | } 87 | 88 | // AddToHelperMethods pulls the string list of help methods in the context and appends another value 89 | // to it. This method helps minimize writing this code in multiple spots throughout. 90 | func AddToHelperMethods(ctx context.Context, newMethod string) string { 91 | helperMethods := "" 92 | if ctx.Value(CtxHelpMethods) != nil { 93 | helperMethods = ctx.Value(CtxHelpMethods).(string) 94 | } 95 | if helperMethods != "" { 96 | helperMethods = fmt.Sprintf("%s, %s", helperMethods, newMethod) 97 | } 98 | return helperMethods 99 | } 100 | -------------------------------------------------------------------------------- /emails/signinemail/signin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 | 7 |

New Device Signed Into Your App

8 |
9 |
10 |

11 | Hi there,
12 |
13 | There was a new sign into your account. 14 |

15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
IP189.0.01.1
BrowserChrome 86.0.34
OSMac OSx
31 |
32 | 33 |

34 | If this was you, you’re all set!
35 |
36 | If this wasn not you, please change your password. You can also enable two-factor authentication to help secure your account. 37 |

38 |
39 |
40 |

Made by KeithWeaver

41 |

42 | 43 | Our Blog 44 | 45 | 46 | Our Privacy Policy 47 | 48 |

49 |

50 |
51 |
52 | 53 | 54 | -------------------------------------------------------------------------------- /cars/models.go: -------------------------------------------------------------------------------- 1 | package cars 2 | 3 | import ( 4 | "errors" 5 | "go.mongodb.org/mongo-driver/bson" 6 | "go.mongodb.org/mongo-driver/bson/primitive" 7 | "time" 8 | ) 9 | 10 | type Car struct { 11 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 12 | Make string `json:"make",bson:"make"` 13 | Model string `json:"model",bson:"model"` 14 | Year int `json:"year",bson:"year"` 15 | Status string `json:"status",bson:"status"` 16 | Email string `json:"email",bson:"email"` 17 | Created time.Time `json:"created",bson:"created"` 18 | } 19 | 20 | type ListCarQuery struct { 21 | Page int `json:"page"` 22 | Limit int `json:"limit"` 23 | Make string `json:"make"` 24 | Model string `json:"model"` 25 | Year int `json:"year"` 26 | } 27 | 28 | type ListCarQueryV1 struct { 29 | Page int `json:"page"` 30 | Limit int `json:"limit"` 31 | Make string `json:"make"` 32 | Model string `json:"model"` 33 | Year int `json:"year"` 34 | } 35 | 36 | func (q *ListCarQuery) Filter(email string) bson.M { 37 | andFilters := []bson.M{ 38 | bson.M{ 39 | "email": email, 40 | }, 41 | } 42 | 43 | if q.Make != "" { 44 | orFilters := []bson.M{ 45 | // Exact match 46 | bson.M{ 47 | "make": q.Make, 48 | }, 49 | // Similar match 50 | bson.M{ 51 | "make": bson.M{ 52 | "$regex": primitive.Regex{ 53 | Pattern: "^" + q.Make + "*", 54 | Options: "i", 55 | }, 56 | }, 57 | }, 58 | } 59 | 60 | andFilters = append(andFilters, bson.M{"$or": orFilters}) 61 | } 62 | 63 | if q.Model != "" { 64 | orFilters := []bson.M{ 65 | // Exact match 66 | bson.M{ 67 | "model": q.Model, 68 | }, 69 | // Similar match 70 | bson.M{ 71 | "model": bson.M{ 72 | "$regex": primitive.Regex{ 73 | Pattern: "^" + q.Model + "*", 74 | Options: "i", 75 | }, 76 | }, 77 | }, 78 | } 79 | 80 | andFilters = append(andFilters, bson.M{"$or": orFilters}) 81 | } 82 | 83 | if q.Year != 0 { 84 | andFilters = append(andFilters, bson.M{"year": q.Year}) 85 | } 86 | 87 | if len(andFilters) == 0 { 88 | // Handle empty and, since there must be one item. 89 | return bson.M{} 90 | } 91 | return bson.M{"$and": andFilters} 92 | } 93 | 94 | type CreateCar struct { 95 | Make string `json:"make"` 96 | Model string `json:"model"` 97 | Year int `json:"year"` 98 | } 99 | 100 | type CreateCarV1 struct { 101 | Make string `json:"make"` 102 | Model string `json:"model"` 103 | Year int `json:"year"` 104 | } 105 | 106 | func (c *CreateCar) Valid() error { 107 | if c.Make == "" { 108 | return errors.New("Error: Make is missing") 109 | } 110 | if c.Model == "" { 111 | return errors.New("Error: Model is missing") 112 | } 113 | if c.Year == 0 { 114 | return errors.New("Error: Year is missing") 115 | } 116 | return nil 117 | } 118 | 119 | type UpdateCar struct { 120 | Make string `json:"make"` 121 | Model string `json:"model"` 122 | Year int `json:"year"` 123 | Status string `json:"status"` 124 | } 125 | 126 | func (u *UpdateCar) Valid() error { 127 | return nil 128 | } 129 | 130 | func (u *UpdateCar) Update() bson.M { 131 | update := bson.M{} 132 | if u.Make != "" { 133 | update["make"] = u.Make 134 | } 135 | if u.Model != "" { 136 | update["model"] = u.Model 137 | } 138 | if u.Year != 0 { 139 | update["year"] = u.Year 140 | } 141 | if u.Status != "" { 142 | update["status"] = u.Status 143 | } 144 | if len(update) == 0 { 145 | return nil 146 | } 147 | return bson.M{"$set": update} 148 | } 149 | 150 | type UpdateCarV1 struct { 151 | Make string `json:"make"` 152 | Model string `json:"model"` 153 | Year int `json:"year"` 154 | Status string `json:"status"` 155 | } 156 | -------------------------------------------------------------------------------- /user/repository.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | // "common" 6 | // "fmt" 7 | "time" 8 | 9 | // "database/sql" 10 | // "github.com/jmoiron/sqlx" 11 | "go.mongodb.org/mongo-driver/bson" 12 | "go.mongodb.org/mongo-driver/bson/primitive" 13 | "go.mongodb.org/mongo-driver/mongo" 14 | ) 15 | 16 | type Repository struct { 17 | db *mongo.Database 18 | usersCollection string 19 | sessionsCollection string 20 | } 21 | 22 | func NewInstanceOfUserRepository(db *mongo.Database) Repository { 23 | return Repository{db: db, usersCollection: "users", sessionsCollection: "sessions"} 24 | } 25 | 26 | func (u *Repository) GetUserByEmail(email string) (bool, User, error) { 27 | var user User 28 | filter := bson.M{"email": email} 29 | count, err := u.db.Collection(u.usersCollection).CountDocuments(context.TODO(), filter) 30 | if err != nil { 31 | return false, User{}, err 32 | } 33 | if count != 1 { 34 | return false, User{}, nil 35 | } 36 | err = u.db.Collection(u.usersCollection).FindOne(context.TODO(), filter).Decode(&user) 37 | if err != nil { 38 | return false, User{}, err 39 | } 40 | 41 | return true, user, nil 42 | } 43 | 44 | func (u *Repository) DoesUserExist(email string) (bool, error) { 45 | exists, _, err := u.GetUserByEmail(email) 46 | return exists, err 47 | } 48 | 49 | func (u *Repository) SaveUser(user User) error { 50 | _, err := u.db.Collection(u.usersCollection).InsertOne(context.TODO(), user) 51 | if err != nil { 52 | return err 53 | } 54 | return nil 55 | } 56 | 57 | func (u *Repository) SaveSession(session Session) (string, error) { 58 | insertResult, err := u.db.Collection(u.sessionsCollection).InsertOne(context.TODO(), session) 59 | if err != nil { 60 | return "", err 61 | } 62 | 63 | return insertResult.InsertedID.(primitive.ObjectID).Hex(), nil 64 | } 65 | 66 | func (u *Repository) GetSessionById(token string) (bool, Session, error) { 67 | docID, err := primitive.ObjectIDFromHex(token) 68 | if err != nil { 69 | return false, Session{}, err 70 | } 71 | 72 | var session Session 73 | filter := bson.M{ 74 | "_id": docID, 75 | "expiry": bson.M{ 76 | "$gte": time.Now(), 77 | }, 78 | } 79 | 80 | count, err := u.db.Collection(u.sessionsCollection).CountDocuments(context.TODO(), filter) 81 | if err != nil { 82 | return false, Session{}, err 83 | } 84 | if count != 1 { 85 | return false, Session{}, nil 86 | } 87 | err = u.db.Collection(u.sessionsCollection).FindOne(context.TODO(), filter).Decode(&session) 88 | if err != nil { 89 | return false, Session{}, err 90 | } 91 | return true, session, nil 92 | } 93 | 94 | func (u *Repository) MarkSessionAsExpired(authToken string) error { 95 | filter := bson.M{"_id": authToken} 96 | update := bson.M{"$set": bson.M{"expiry": time.Now()}} 97 | _, err := u.db.Collection(u.sessionsCollection).UpdateOne(context.TODO(), filter, update) 98 | if err != nil { 99 | return err 100 | } 101 | return nil 102 | } 103 | 104 | func (u *Repository) UpdateAccountLocked(email string, accountLock bool) error { 105 | filter := bson.M{"email": email} 106 | update := bson.M{"$set": bson.M{"accountLocked": accountLock}} 107 | _, err := u.db.Collection(u.usersCollection).UpdateOne(context.TODO(), filter, update) 108 | if err != nil { 109 | return err 110 | } 111 | return nil 112 | } 113 | 114 | func (u *Repository) UpdateOrAddTrustedIPToUser(email string, newIP IP) error { 115 | // Check if the IP address already exists 116 | filter := bson.M{"email": email, "trustedIPs.address": newIP.Address} 117 | count, err := u.db.Collection(u.usersCollection).CountDocuments(context.TODO(), filter) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | if count > 0 { 123 | // Update the current IP information (Docs: https://docs.mongodb.com/manual/reference/operator/update/positional/#update-values-in-an-array) 124 | update := bson.M{"$set": bson.M{ "trustedIPs.$": newIP}} 125 | _, err = u.db.Collection(u.usersCollection).UpdateOne(context.Background(), filter, update) 126 | if err != nil { 127 | return err 128 | } 129 | } 130 | 131 | // Push new one (Docs: https://docs.mongodb.com/manual/reference/operator/update/push/#examples) 132 | update := bson.M{"$push": bson.M{"trustedIPs": newIP}} 133 | _, err = u.db.Collection(u.usersCollection).UpdateOne(context.Background(), bson.M{"email": email}, update) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | return nil 139 | } 140 | 141 | func (u *Repository) UnlockSession(authToken string) error { 142 | filter := bson.M{"_id": authToken} 143 | update := bson.M{"$set": bson.M{"locked": false}} 144 | _, err := u.db.Collection(u.sessionsCollection).UpdateOne(context.TODO(), filter, update) 145 | if err != nil { 146 | return err 147 | } 148 | return nil 149 | } 150 | 151 | func (u *Repository) UpdatePassword(email string, newPassword string) error { 152 | filter := bson.M{"email": email} 153 | update := bson.M{"$set": bson.M{"password": newPassword}} 154 | _, err := u.db.Collection(u.usersCollection).UpdateOne(context.TODO(), filter, update) 155 | if err != nil { 156 | return err 157 | } 158 | return nil 159 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go Boilerplate 2 | 3 | ## Running 4 | 5 | ``` 6 | go run main.go 7 | ``` 8 | 9 | You will need to update `isValidPassword` in `services/user_service.go`. 10 | 11 | 12 | ## Code Structure 13 | 14 | ### Why move from handlers, services, repositories in individual folders to domain specific? 15 | 16 | For extendability is the short answer. 17 | 18 | In the original version of this boilerplate, I had `services/`, `handlers/`, `repositories/` and `models/` all be folders. In each folder it would be `_.go`. For example, `handlers/cars_handler.go`. 19 | 20 | This domain specific approach allows for new packages to be added and deleted depending on the package. I can add a package for individual service, and if you do not want it, just delete it. 21 | 22 | ### Logging & Context 23 | 24 | 25 | ## Database 26 | 27 | TODO 28 | 29 | 30 | ## Demo Commands 31 | 32 | ### Sign up 33 | 34 | ``` 35 | curl --location --request POST 'http://localhost:8080/user/signup' \ 36 | --header 'Content-Type: application/json' \ 37 | --data-raw '{ 38 | "email": "foobar@demo.com", 39 | "password": "test123" 40 | }' 41 | ``` 42 | 43 | Response 44 | 45 | ``` 46 | { 47 | "message": "Signed up", 48 | "token": "5f2dde6175bd1baf7e9a5806" 49 | } 50 | ``` 51 | 52 | ### Sign In 53 | 54 | ``` 55 | curl --location --request POST 'http://localhost:8080/user/signin' \ 56 | --header 'Content-Type: application/json' \ 57 | --data-raw '{ 58 | "email": "foobar@demo.com", 59 | "password": "test123" 60 | }' 61 | ``` 62 | 63 | Response 64 | 65 | ``` 66 | { 67 | "message": "Signed in", 68 | "token": "5f2ddeb075bd1baf7e9a5807" 69 | } 70 | ``` 71 | 72 | ### Log Out 73 | 74 | ``` 75 | curl --location --request POST 'http://localhost:8080/user/logout' \ 76 | --header 'Content-Type: application/json' \ 77 | --header 'Authorization: Bearer 5f2e9d3001e3a273ed558c49' \ 78 | --data-raw '{}' 79 | ``` 80 | 81 | Response 82 | 83 | ``` 84 | { 85 | "message": "Logged out" 86 | } 87 | ``` 88 | 89 | ### Create Car 90 | 91 | ``` 92 | curl --location --request POST 'http://localhost:8080/cars/' \ 93 | --header 'Content-Type: application/json' \ 94 | --header 'Authorization: Bearer 5f2e9f23a9aefb542b3a8e18' \ 95 | --data-raw '{ 96 | "make": "Mazda", 97 | "model": "3" 98 | }' 99 | ``` 100 | 101 | Response 102 | 103 | ``` 104 | { 105 | "message": "Error: Year is missing" 106 | } 107 | ``` 108 | 109 | ``` 110 | curl --location --request POST 'http://localhost:8080/cars/' \ 111 | --header 'Content-Type: application/json' \ 112 | --header 'Authorization: Bearer 5f2e9f23a9aefb542b3a8e18' \ 113 | --data-raw '{ 114 | "make": "Mazda", 115 | "model": "3", 116 | "year": 2013 117 | }' 118 | ``` 119 | 120 | Response 121 | 122 | ``` 123 | { 124 | "message": "Created car" 125 | } 126 | ``` 127 | 128 | ### Get Car 129 | 130 | ``` 131 | curl --location --request GET 'http://localhost:8080/cars/5f2ea7d2dd45bb607bc45707' \ 132 | --header 'Content-Type: application/json' \ 133 | --header 'Authorization: Bearer 5f2e9f23a9aefb542b3a8e18' 134 | ``` 135 | 136 | Response 137 | 138 | ``` 139 | { 140 | "car": { 141 | "id": "5f2ea7d2dd45bb607bc45707", 142 | "make": "Mazda", 143 | "model": "3", 144 | "year": 2013, 145 | "status": "", 146 | "email": "foobar@demo.com", 147 | "created": "2020-08-08T13:25:38.6Z" 148 | }, 149 | "message": "Car retrieved" 150 | } 151 | ``` 152 | 153 | ### Delete Car 154 | 155 | ``` 156 | curl --location --request DELETE 'http://localhost:8080/cars/5f2ea7d2dd45bb607bc45707' \ 157 | --header 'Content-Type: application/json' \ 158 | --header 'Authorization: Bearer 5f2e9f23a9aefb542b3a8e18' 159 | ``` 160 | 161 | Response 162 | 163 | ``` 164 | { 165 | "message": "Deleted car" 166 | } 167 | ``` 168 | 169 | ### Update Car 170 | 171 | ``` 172 | curl --location --request PUT 'http://localhost:8080/cars/5f2ea852dd45bb607bc45708' \ 173 | --header 'Authorization: Bearer 5f2e9f23a9aefb542b3a8e18' \ 174 | --header 'Content-Type: application/json' \ 175 | --data-raw '{ 176 | "year": 2018 177 | }' 178 | ``` 179 | 180 | Response 181 | 182 | ``` 183 | { 184 | "message": "Updated car" 185 | } 186 | ``` 187 | 188 | ### List Cars 189 | 190 | ``` 191 | curl --location --request GET 'http://localhost:8080/cars/' \ 192 | --header 'Authorization: Bearer 5f2e9f23a9aefb542b3a8e18' \ 193 | --header 'Content-Type: application/json' 194 | ``` 195 | 196 | Response 197 | 198 | ``` 199 | { 200 | "cars": [ 201 | { 202 | "id": "5f2ea85bdd45bb607bc45709", 203 | "make": "Landrover", 204 | "model": "Defender", 205 | "year": 2019, 206 | "status": "", 207 | "email": "foobar@demo.com", 208 | "created": "2020-08-08T13:27:55.132Z" 209 | }, 210 | { 211 | "id": "5f2ea852dd45bb607bc45708", 212 | "make": "Landrover", 213 | "model": "Rangerover", 214 | "year": 2018, 215 | "status": "", 216 | "email": "foobar@demo.com", 217 | "created": "2020-08-08T13:27:46.969Z" 218 | } 219 | ], 220 | "message": "Cars retrieved" 221 | } 222 | ``` 223 | -------------------------------------------------------------------------------- /user/models.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "time" 7 | // "go.mongodb.org/mongo-driver/bson" 8 | "go.mongodb.org/mongo-driver/bson/primitive" 9 | ) 10 | 11 | type User struct { 12 | Email string `json:"email" bson:"email"` 13 | Password string `json:"password" bson:"password"` 14 | Name string `json:"name" bson:"name"` 15 | Created time.Time `json:"created" bson:"created"` 16 | VerifiedEmail bool `json:"verified" bson:"verified"` 17 | VerificationCode string `json:"-" bson:"validationCode"` // On sign up or requested, user is sent a temporary verification code 18 | VerificationExpiryTime time.Time `json:"-" bson:"validationExpiryTime" ` // The timeframe when the verification code is sent 19 | TrustedIPs []IP `json:"trustedIPs" bson:"trustedIPs"` 20 | InvalidIPs []IP `json:"invalidIPs" bson:"invalidIPs"` 21 | AccountLocked bool `json:"accountLocked" bson:"accountLocked"` // Stop new sign ins from happening 22 | KnownDevices []Device `json:"knownDevices" bson:"knownDevices"` 23 | } 24 | 25 | func (u *User) Greeting() string { 26 | if u.Name != "" { 27 | return u.Name 28 | } 29 | return "there" 30 | } 31 | 32 | type IP struct { 33 | Address string `json:"address" bson:"address"` 34 | LocationFound bool `json:"locationFound" bson:"locationFound"` // Boolean flag that indicates other location based attributes are set 35 | Latitude float64 `json:"latitude" bson:"latitude"` // Not required 36 | Longitude float64 `json:"longitude" bson:"longitude"` // Not required 37 | Country string `json:"country" bson:"country"` 38 | Region string `json:"region" bson:"region"` 39 | City string `json:"city" bson:"city"` 40 | } 41 | 42 | func (u *User) HasTrustedIP(ipAddress string) bool { 43 | for _, ip := range u.TrustedIPs { 44 | if ip.Address == ipAddress { 45 | return true 46 | } 47 | } 48 | return false 49 | } 50 | 51 | func (u *User) HasInvalidIPs(ipAddress string) bool { 52 | for _, ip := range u.InvalidIPs { 53 | if ip.Address == ipAddress { 54 | return true 55 | } 56 | } 57 | return false 58 | } 59 | 60 | type Device struct { 61 | Name string `json:"name" bson:"name"` 62 | Mobile bool `json:"mobile" bson:"mobile"` 63 | Bot bool `json:"bot" bson:"bot"` 64 | Mozilla string `json:"mozilla" bson:"mozilla"` 65 | Platform string `json:"platform" bson:"platform"` 66 | OperatingSystem string `json:"operatingSystem" bson:"operatingSystem"` 67 | Engine string `json:"engine" bson:"engine"` 68 | EngineVersion string `json:"engineVersion" bson:"engineVersion"` 69 | Browser string `json:"browser" bson:"browser"` 70 | BrowserVersion string `json:"browserVersion" bson:"browserVersion"` 71 | ValidDevice bool `json:"validDevice" bson:"validDevice"` // Starts as true and user can change to false. 72 | } 73 | 74 | type Session struct { 75 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 76 | Email string `json:"email" bson:"email"` 77 | Expiry time.Time `json:"expiry" bson:"expiry"` 78 | Created time.Time `json:"created" bson:"created"` 79 | Locked bool `json:"locked" bson:"locked"` 80 | UnlockCode string `json:"unlockCode" bson:"unlockCode"` // Second layer of security, on suspicious signs in, emails code to confirm 81 | Device Device `json:"device" bson:"device,omitempty"` 82 | } 83 | 84 | type SignInBody struct { 85 | Email string `json:"email"` 86 | Password string `json:"password"` 87 | } 88 | 89 | type SignUpBody struct { 90 | Email string `json:"email"` 91 | Password string `json:"password"` 92 | Name string `json:"name"` 93 | } 94 | 95 | type UnlockSessionBody struct { 96 | Code string `json:"code"` 97 | } 98 | 99 | func (b *UnlockSessionBody) Validate() error { 100 | if b.Code == "" { 101 | return errors.New("code is required") 102 | } 103 | return nil 104 | } 105 | 106 | type SendForgotPasswordBody struct { 107 | Email string `json:"email"` 108 | } 109 | 110 | func (b *SendForgotPasswordBody) Validate() error { 111 | if b.Email == "" { 112 | return errors.New("email is required") 113 | } 114 | return nil 115 | } 116 | 117 | func (b *SendForgotPasswordBody) GetFormattedEmail() string { 118 | email := strings.Trim(b.Email, " ") 119 | email = strings.ToLower(email) 120 | return email 121 | } 122 | 123 | type ResetForgotPasswordBody struct { 124 | Email string `json:"email"` 125 | Code string `json:"code"` 126 | NewPassword string `json:"newPassword"` 127 | } 128 | 129 | func (b *ResetForgotPasswordBody) Validate() error { 130 | if b.Email == "" { 131 | return errors.New("email is required") 132 | } 133 | if b.Code == "" { 134 | return errors.New("code is required") 135 | } 136 | if b.NewPassword == "" { 137 | return errors.New("password is required") 138 | } 139 | return nil 140 | } 141 | 142 | func (b *ResetForgotPasswordBody) GetFormattedEmail() string { 143 | email := strings.Trim(b.Email, " ") 144 | email = strings.ToLower(email) 145 | return email 146 | } 147 | 148 | type ForgotPasswordCode struct { 149 | ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` 150 | Email string `json:"email" bson:"email"` 151 | Code string `json:"-" bson:"code"` 152 | Created time.Time `json:"created" bson:"created"` 153 | Expiry time.Time `json:"expiry" bson:"expiry"` 154 | } -------------------------------------------------------------------------------- /cars/handlers.go: -------------------------------------------------------------------------------- 1 | package cars 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/gin-gonic/gin" 7 | validator "github.com/go-playground/validator/v10" 8 | "github.com/google/uuid" 9 | "go-boilerplate/logging" 10 | "go-boilerplate/user" 11 | "strconv" 12 | // "strings" 13 | ) 14 | 15 | type Handlers struct { 16 | logger logging.Logger 17 | carsService Services 18 | } 19 | 20 | func NewInstanceOfCarsHandlers(logger logging.Logger, carsService Services) *Handlers { 21 | return &Handlers{logger, carsService} 22 | } 23 | 24 | func (u *Handlers) GetSession(c *gin.Context) (user.Session, bool) { 25 | i, exists := c.Get("session") 26 | if !exists { 27 | return user.Session{}, false 28 | } 29 | session, ok := i.(user.Session) 30 | if !ok { 31 | return user.Session{}, false 32 | } 33 | return session, true 34 | } 35 | 36 | func (u *Handlers) GetAll(c *gin.Context) { 37 | ctx := context.Background() 38 | ctx = context.WithValue(ctx, logging.CtxDomain, "Cars") 39 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "GetAll") 40 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 41 | 42 | u.logger.Info(ctx, "Called") 43 | 44 | session, exists := u.GetSession(c) 45 | if !exists { 46 | c.JSON(403, gin.H{"message": "error: unauthorized"}) 47 | return 48 | } 49 | 50 | page := c.DefaultQuery("page", "1") 51 | limit := c.DefaultQuery("limit", "25") 52 | make := c.DefaultQuery("make", "") 53 | model := c.DefaultQuery("model", "") 54 | year := c.DefaultQuery("year", "0") 55 | 56 | yearInt, err := strconv.Atoi(year) 57 | if err != nil { 58 | c.JSON(400, gin.H{"message": err.Error()}) 59 | return 60 | } 61 | 62 | pageInt, err := strconv.Atoi(page) 63 | if err != nil { 64 | c.JSON(400, gin.H{"message": err.Error()}) 65 | return 66 | } 67 | 68 | limitInt, err := strconv.Atoi(limit) 69 | if err != nil { 70 | c.JSON(400, gin.H{"message": err.Error()}) 71 | return 72 | } 73 | 74 | query := ListCarQuery{ 75 | Page: pageInt, 76 | Limit: limitInt, 77 | Make: make, 78 | Model: model, 79 | Year: yearInt, 80 | } 81 | 82 | v := validator.New() 83 | if err := v.Struct(query); err != nil { 84 | fmt.Print("Validation failed.") 85 | c.JSON(400, gin.H{"message": err.Error()}) 86 | return 87 | } 88 | 89 | cars, err := u.carsService.GetAll(ctx, session, query) 90 | if err != nil { 91 | c.JSON(400, gin.H{"message": err.Error()}) 92 | return 93 | } 94 | if cars == nil { 95 | cars = []Car{} 96 | } 97 | c.JSON(200, gin.H{"message": "Cars retrieved", "cars": cars}) 98 | return 99 | } 100 | 101 | func (u *Handlers) GetByID(c *gin.Context) { 102 | ctx := context.Background() 103 | ctx = context.WithValue(ctx, logging.CtxDomain, "Cars") 104 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "GetByID") 105 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 106 | 107 | carsID := c.Param("id") 108 | 109 | session, exists := u.GetSession(c) 110 | if !exists { 111 | c.JSON(403, gin.H{"message": "error: unauthorized"}) 112 | return 113 | } 114 | 115 | car, err := u.carsService.GetByID(ctx, session, carsID) 116 | if err != nil { 117 | c.JSON(400, gin.H{"message": err.Error()}) 118 | return 119 | } 120 | c.JSON(200, gin.H{"message": "Car retrieved", "car": car}) 121 | return 122 | } 123 | 124 | func (u *Handlers) Create(c *gin.Context) { 125 | ctx := context.Background() 126 | ctx = context.WithValue(ctx, logging.CtxDomain, "Cars") 127 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "Create") 128 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 129 | 130 | var body CreateCar 131 | if err := c.ShouldBindJSON(&body); err != nil { 132 | c.JSON(400, gin.H{"message": err.Error()}) 133 | return 134 | } 135 | 136 | if err := body.Valid(); err != nil { 137 | c.JSON(400, gin.H{"message": err.Error()}) 138 | return 139 | } 140 | 141 | session, exists := u.GetSession(c) 142 | if !exists { 143 | c.JSON(403, gin.H{"message": "error: unauthorized"}) 144 | return 145 | } 146 | 147 | v := validator.New() 148 | if err := v.Struct(body); err != nil { 149 | c.JSON(400, gin.H{"message": err.Error()}) 150 | return 151 | } 152 | 153 | err := u.carsService.Create(ctx, session, body) 154 | if err != nil { 155 | c.JSON(400, gin.H{"message": err.Error()}) 156 | return 157 | } 158 | c.JSON(200, gin.H{"message": "Created car"}) 159 | return 160 | } 161 | 162 | func (u *Handlers) Update(c *gin.Context) { 163 | ctx := context.Background() 164 | ctx = context.WithValue(ctx, logging.CtxDomain, "Cars") 165 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "Update") 166 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 167 | 168 | carsID := c.Param("id") 169 | 170 | var body UpdateCar 171 | if err := c.ShouldBindJSON(&body); err != nil { 172 | c.JSON(400, gin.H{"message": err.Error()}) 173 | return 174 | } 175 | 176 | if err := body.Valid(); err != nil { 177 | c.JSON(400, gin.H{"message": err.Error()}) 178 | return 179 | } 180 | 181 | session, exists := u.GetSession(c) 182 | if !exists { 183 | c.JSON(403, gin.H{"message": "error: unauthorized"}) 184 | return 185 | } 186 | 187 | v := validator.New() 188 | if err := v.Struct(body); err != nil { 189 | c.JSON(400, gin.H{"message": err.Error()}) 190 | return 191 | } 192 | 193 | err := u.carsService.Update(ctx, session, carsID, body) 194 | if err != nil { 195 | c.JSON(400, gin.H{"message": err.Error()}) 196 | return 197 | } 198 | c.JSON(200, gin.H{"message": "Updated car"}) 199 | return 200 | } 201 | 202 | func (u *Handlers) Delete(c *gin.Context) { 203 | ctx := context.Background() 204 | ctx = context.WithValue(ctx, logging.CtxDomain, "Cars") 205 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "Delete") 206 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 207 | 208 | session, exists := u.GetSession(c) 209 | if !exists { 210 | c.JSON(403, gin.H{"message": "error: unauthorized"}) 211 | return 212 | } 213 | 214 | carsID := c.Param("id") 215 | 216 | err := u.carsService.Delete(ctx, session, carsID) 217 | if err != nil { 218 | c.JSON(400, gin.H{"message": err.Error()}) 219 | return 220 | } 221 | c.JSON(200, gin.H{"message": "Deleted car"}) 222 | return 223 | } 224 | -------------------------------------------------------------------------------- /user/handlers.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/google/uuid" 7 | "github.com/mssola/user_agent" 8 | "go-boilerplate/common" 9 | 10 | // "fmt" 11 | "github.com/gin-gonic/gin" 12 | validator "github.com/go-playground/validator/v10" 13 | "go-boilerplate/logging" 14 | "strings" 15 | ) 16 | 17 | type Handlers struct { 18 | logger logging.Logger 19 | userServices Services 20 | } 21 | 22 | func NewInstanceOfUserHandlers(logger logging.Logger, userServices Services) *Handlers { 23 | return &Handlers{logger, userServices} 24 | } 25 | 26 | func (u *Handlers) SignIn(c *gin.Context) { 27 | ctx := context.Background() 28 | ctx = context.WithValue(ctx, logging.CtxDomain, "user") 29 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "SignIn") 30 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 31 | 32 | // Capture IP 33 | clientIP := c.ClientIP() 34 | 35 | ctx = context.WithValue(ctx, logging.CtxClientIP, clientIP) 36 | 37 | // Capture User Agent header 38 | var userAgent *user_agent.UserAgent 39 | if c.Request.Header["User-Agent"] != nil && len(c.Request.Header["User-Agent"]) > 0 { 40 | userAgent = user_agent.New(c.Request.Header["User-Agent"][0]) 41 | } 42 | 43 | 44 | var body SignInBody 45 | if err := c.ShouldBindJSON(&body); err != nil { 46 | common.ReturnErrorResponse(c, &common.Error{StatusCode: 400}) 47 | return 48 | } 49 | 50 | v := validator.New() 51 | if err := v.Struct(body); err != nil { 52 | common.ReturnErrorResponse(c, &common.Error{StatusCode: 400, Message: err.Error()}) 53 | return 54 | } 55 | 56 | token, sessionLocked, err := u.userServices.SignIn(ctx, userAgent, clientIP, body) 57 | if err != nil { 58 | common.ReturnErrorResponse(c, err) 59 | return 60 | } 61 | 62 | c.JSON(200, gin.H{"message": "Signed in", "token": token, "sessionLocked": sessionLocked}) 63 | return 64 | } 65 | 66 | func (u *Handlers) SignUp(c *gin.Context) { 67 | ctx := context.Background() 68 | ctx = context.WithValue(ctx, logging.CtxDomain, "user") 69 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "SignUp") 70 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 71 | 72 | // Capture IP 73 | clientIP := c.ClientIP() 74 | 75 | ctx = context.WithValue(ctx, logging.CtxClientIP, clientIP) 76 | 77 | // Capture User Agent header 78 | var userAgent *user_agent.UserAgent 79 | if c.Request.Header["User-Agent"] != nil && len(c.Request.Header["User-Agent"]) > 0 { 80 | userAgent = user_agent.New(c.Request.Header["User-Agent"][0]) 81 | } 82 | 83 | var body SignUpBody 84 | if err := c.ShouldBindJSON(&body); err != nil { 85 | common.ReturnErrorResponse(c, &common.Error{StatusCode: 400}) 86 | return 87 | } 88 | 89 | v := validator.New() 90 | if err := v.Struct(body); err != nil { 91 | common.ReturnErrorResponse(c, &common.Error{StatusCode: 400, Message: err.Error()}) 92 | return 93 | } 94 | 95 | token, sessionLocked, err := u.userServices.SignUp(ctx, userAgent, clientIP, body) 96 | if err != nil { 97 | common.ReturnErrorResponse(c, err) 98 | return 99 | } 100 | 101 | c.JSON(200, gin.H{"message": "Signed up", "token": token, "sessionLocked": sessionLocked}) 102 | return 103 | } 104 | 105 | func (u *Handlers) LogOut(c *gin.Context) { 106 | ctx := context.Background() 107 | ctx = context.WithValue(ctx, logging.CtxDomain, "user") 108 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "LogOut") 109 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 110 | ctx = context.WithValue(ctx, logging.CtxClientIP, c.ClientIP()) 111 | 112 | authToken := c.Request.Header.Get("Authorization") 113 | authToken = strings.ReplaceAll(authToken, "Bearer ", "") 114 | 115 | err := u.userServices.LogOut(ctx, authToken) 116 | if err != nil { 117 | common.ReturnErrorResponse(c, err) 118 | return 119 | } 120 | c.JSON(200, gin.H{"message": "Logged out"}) 121 | return 122 | } 123 | 124 | func (u *Handlers) UnlockSession(c *gin.Context) { 125 | ctx := context.Background() 126 | ctx = context.WithValue(ctx, logging.CtxDomain, "user") 127 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "UnlockSession") 128 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 129 | 130 | // Capture IP 131 | clientIP := c.ClientIP() 132 | ctx = context.WithValue(ctx, logging.CtxClientIP, clientIP) 133 | 134 | // Get auth token 135 | if c.Request.Header["Authorization"] == nil || len(c.Request.Header["Authorization"]) == 0 { 136 | u.logger.Warning(ctx, "no auth token provided", errors.New("unauthorized")) 137 | common.ReturnErrorResponse(c, &common.Error{ 138 | StatusCode: 403, 139 | }) 140 | return 141 | } 142 | authToken := c.Request.Header["Authorization"][0] 143 | authToken = strings.ReplaceAll(authToken, "Bearer ", "") 144 | authToken = strings.Trim(authToken, " ") 145 | 146 | if authToken == "" { 147 | u.logger.Warning(ctx, "no auth token provided 2", errors.New("unauthorized")) 148 | common.ReturnErrorResponse(c, &common.Error{ 149 | StatusCode: 403, 150 | }) 151 | return 152 | } 153 | 154 | // Get code from request body 155 | var body UnlockSessionBody 156 | if err := c.ShouldBindJSON(&body); err != nil { 157 | u.logger.Warning(ctx, "invalid request body", err) 158 | common.ReturnErrorResponse(c, &common.Error{StatusCode: 400}) 159 | return 160 | } 161 | 162 | if err := body.Validate(); err != nil { 163 | u.logger.Warning(ctx, "validation failed on request body", err) 164 | common.ReturnErrorResponse(c, &common.Error{StatusCode: 400, Message: err.Error()}) 165 | return 166 | } 167 | 168 | // Call the service 169 | err := u.userServices.UnlockSession(ctx, clientIP, authToken, body) 170 | if err != nil { 171 | common.ReturnErrorResponse(c, err) 172 | return 173 | } 174 | 175 | c.JSON(200, gin.H{"message": "Session unlocked"}) 176 | return 177 | } 178 | 179 | func (u *Handlers) SendForgotPassword(c *gin.Context) { 180 | ctx := context.Background() 181 | ctx = context.WithValue(ctx, logging.CtxDomain, "user") 182 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "SendForgotPassword") 183 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 184 | 185 | // Capture IP 186 | clientIP := c.ClientIP() 187 | ctx = context.WithValue(ctx, logging.CtxClientIP, clientIP) 188 | 189 | // Get code from request body 190 | var body SendForgotPasswordBody 191 | if err := c.ShouldBindJSON(&body); err != nil { 192 | u.logger.Warning(ctx, "invalid request body", err) 193 | common.ReturnErrorResponse(c, &common.Error{StatusCode: 400}) 194 | return 195 | } 196 | 197 | if err := body.Validate(); err != nil { 198 | u.logger.Warning(ctx, "validation failed on request body", err) 199 | common.ReturnErrorResponse(c, &common.Error{StatusCode: 400, Message: err.Error()}) 200 | return 201 | } 202 | 203 | // Call the service 204 | err := u.userServices.SendForgotPassword(ctx, clientIP, body) 205 | if err != nil { 206 | common.ReturnErrorResponse(c, err) 207 | return 208 | } 209 | 210 | c.JSON(200, gin.H{"message": "Email sent"}) 211 | return 212 | } 213 | 214 | func (u *Handlers) ForgotPassword(c *gin.Context) { 215 | ctx := context.Background() 216 | ctx = context.WithValue(ctx, logging.CtxDomain, "user") 217 | ctx = context.WithValue(ctx, logging.CtxHandlerMethod, "ForgotPassword") 218 | ctx = context.WithValue(ctx, logging.CtxRequestID, uuid.New().String()) 219 | 220 | // Capture IP 221 | clientIP := c.ClientIP() 222 | ctx = context.WithValue(ctx, logging.CtxClientIP, clientIP) 223 | 224 | // Get code from request body 225 | var body ResetForgotPasswordBody 226 | if err := c.ShouldBindJSON(&body); err != nil { 227 | u.logger.Warning(ctx, "invalid request body", err) 228 | common.ReturnErrorResponse(c, &common.Error{StatusCode: 400}) 229 | return 230 | } 231 | 232 | if err := body.Validate(); err != nil { 233 | u.logger.Warning(ctx, "validation failed on request body", err) 234 | common.ReturnErrorResponse(c, &common.Error{StatusCode: 400, Message: err.Error()}) 235 | return 236 | } 237 | 238 | // Call the service 239 | err := u.userServices.ForgotPassword(ctx, clientIP, body) 240 | if err != nil { 241 | common.ReturnErrorResponse(c, err) 242 | return 243 | } 244 | 245 | c.JSON(200, gin.H{"message": "Password has been reset"}) 246 | return 247 | 248 | } -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/aws/aws-sdk-go v1.34.28 h1:sscPpn/Ns3i0F4HPEWAVcwdIRaZZCuL7llJ2/60yPIk= 3 | github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= 4 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 5 | github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= 6 | github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= 7 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 8 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= 9 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 14 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 15 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 16 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 17 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 18 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 19 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 20 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 21 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 22 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 23 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 24 | github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= 25 | github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 26 | github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= 27 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 28 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= 29 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 30 | github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= 31 | github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= 32 | github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= 33 | github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= 34 | github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= 35 | github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= 36 | github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= 37 | github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= 38 | github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= 39 | github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= 40 | github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= 41 | github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= 42 | github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= 43 | github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= 44 | github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= 45 | github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= 46 | github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= 47 | github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= 48 | github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= 49 | github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= 50 | github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= 51 | github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= 52 | github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= 53 | github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= 54 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 55 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 56 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 57 | github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= 58 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 59 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 60 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 61 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 62 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 63 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 64 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 65 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 66 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 67 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 68 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 69 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 70 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 71 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 72 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 73 | github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= 74 | github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= 75 | github.com/klauspost/compress v1.9.5 h1:U+CaK85mrNNb4k8BNOfgJtJ/gr6kswUCFj6miSzVC6M= 76 | github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= 77 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 78 | github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= 79 | github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 80 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 81 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 82 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 83 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 84 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 85 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 86 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 87 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 88 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 89 | github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= 90 | github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= 91 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 92 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 93 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 94 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 95 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 96 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 97 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 98 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= 99 | github.com/mssola/user_agent v0.5.2 h1:CZkTUahjL1+OcZ5zv3kZr8QiJ8jy2H08vZIEkBeRbxo= 100 | github.com/mssola/user_agent v0.5.2/go.mod h1:TTPno8LPY3wAIEKRpAtkdMT0f8SE24pLRGPahjCH4uw= 101 | github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= 102 | github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= 103 | github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= 104 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 105 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 106 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 107 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 108 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 109 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 110 | github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 111 | github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 112 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 113 | github.com/sendgrid/rest v2.6.2+incompatible h1:zGMNhccsPkIc8SvU9x+qdDz2qhFoGUPGGC4mMvTondA= 114 | github.com/sendgrid/rest v2.6.2+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= 115 | github.com/sendgrid/sendgrid-go v3.8.0+incompatible h1:7yoUFMwT+jDI2ArBpC6zvtuQj1RUyYfCDl7zZea3XV4= 116 | github.com/sendgrid/sendgrid-go v3.8.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= 117 | github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 118 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 119 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 120 | github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 121 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 122 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 123 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 124 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 125 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 126 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 127 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 128 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 129 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 130 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 131 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 132 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 133 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 134 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 135 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 136 | github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= 137 | github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= 138 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 139 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 140 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 141 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 142 | github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= 143 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 144 | github.com/xdg-go/scram v1.0.2 h1:akYIkZ28e6A96dkWNJQu3nmCzH3YfwMPQExUYDaRv7w= 145 | github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= 146 | github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc= 147 | github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= 148 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= 149 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= 150 | go.mongodb.org/mongo-driver v1.5.1 h1:9nOVLGDfOaZ9R0tBumx/BcuqkbFpyTCU2r/Po7A2azI= 151 | go.mongodb.org/mongo-driver v1.5.1/go.mod h1:gRXCHX4Jo7J0IJ1oDQyUxF7jfy19UfxniMS4xxMmUqw= 152 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 153 | golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= 154 | golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 155 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 156 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 157 | golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 158 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 159 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 160 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 161 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 162 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 163 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 164 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 165 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 166 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 167 | golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 168 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 169 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= 170 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 171 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 172 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 173 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 174 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 175 | golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 176 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 177 | golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 178 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 179 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 180 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 181 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 182 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 183 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 184 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 185 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 186 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 187 | golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 188 | golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 189 | golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 190 | golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 191 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 192 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 193 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 194 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 195 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 196 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 197 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 198 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 199 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 200 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 201 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 202 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 203 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 204 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 205 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 206 | -------------------------------------------------------------------------------- /user/services.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/google/uuid" 8 | "github.com/mssola/user_agent" 9 | "go-boilerplate/common" 10 | "go-boilerplate/emails/accountlockemail" 11 | "go-boilerplate/emails/forgotpasswordemail" 12 | "go-boilerplate/emails/sessionunlockemail" 13 | "go-boilerplate/emails/signinemail" 14 | "go-boilerplate/emails/verifyemail" 15 | "go-boilerplate/logging" 16 | "golang.org/x/crypto/bcrypt" 17 | "strings" 18 | "time" 19 | "unicode" 20 | 21 | // "github.com/google/uuid" 22 | ) 23 | 24 | type Services struct { 25 | logger logging.Logger 26 | userRepository Repository 27 | forgotPasswordRepository ForgotPasswordRepository 28 | } 29 | 30 | type ServiceContract interface { 31 | SignUp(ctx context.Context, userAgent *user_agent.UserAgent, currentIP string, body SignUpBody) (string, bool, *common.Error) 32 | SignIn(ctx context.Context, userAgent *user_agent.UserAgent, currentIP string, body SignInBody) (string, bool, *common.Error) 33 | LogOut(authToken string) error 34 | } 35 | 36 | func NewInstanceOfUserServices(logger logging.Logger, userRepository Repository, forgotPasswordRepository ForgotPasswordRepository) Services { 37 | return Services{logger, userRepository, forgotPasswordRepository} 38 | } 39 | 40 | // SignUp signs up the new account (or signs in the user). 41 | func (s *Services) SignUp(ctx context.Context, userAgent *user_agent.UserAgent, currentIP string, body SignUpBody) (string, bool, *common.Error) { 42 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "SignUp") 43 | 44 | emailLowerCase := strings.ToLower(body.Email) 45 | emailTrimmed := strings.Trim(emailLowerCase, " ") 46 | 47 | // Verify password meets sign up requirements 48 | if !s.isValidPassword(ctx, body.Password) { 49 | return "", false, &common.Error{ 50 | StatusCode: 400, 51 | Message: "error: Your password does not meet requirements.", 52 | } 53 | } 54 | 55 | // Check for user 56 | userExists, err := s.userRepository.DoesUserExist(emailTrimmed) 57 | if err != nil { 58 | return "", false, &common.Error{ 59 | StatusCode: 500, 60 | } 61 | } 62 | if userExists { 63 | // Just try signing them in 64 | return s.signIn(ctx, false, userAgent, currentIP, emailTrimmed, body.Password) 65 | } 66 | encryptedPassword, err := s.getEncryptedPassword(body.Password) 67 | 68 | // Save the device they are signing up as a known device 69 | engine, engineVersion := userAgent.Engine() 70 | browserName, browserVersion := userAgent.Browser() 71 | knownDevices := []Device{{ 72 | Name: "Sign Up Device", 73 | Mobile: userAgent.Mobile(), 74 | Bot: userAgent.Bot(), 75 | Mozilla: userAgent.Mozilla(), 76 | Platform: userAgent.Platform(), 77 | OperatingSystem: userAgent.OS(), 78 | Engine: engine, 79 | EngineVersion: engineVersion, 80 | Browser: browserName, 81 | BrowserVersion: browserVersion, 82 | ValidDevice: true, 83 | }} 84 | 85 | // Create verification code 86 | verificationCode := uuid.New().String() 87 | now := time.Now() 88 | verificationExpiry := now.Add(time.Hour * time.Duration(1)) // Expires in 1 hour 89 | 90 | // Sign up user 91 | newUser := User{ 92 | Email: emailTrimmed, 93 | Password: encryptedPassword, 94 | Name: body.Name, 95 | Created: time.Now(), 96 | VerifiedEmail: false, 97 | VerificationCode: verificationCode, 98 | VerificationExpiryTime: verificationExpiry, 99 | TrustedIPs: []IP{}, 100 | InvalidIPs: []IP{}, 101 | KnownDevices: knownDevices, 102 | } 103 | err = s.userRepository.SaveUser(newUser) 104 | if err != nil { 105 | s.logger.Warning(ctx, "failed to save user", err) 106 | return "", false, &common.Error{ 107 | StatusCode: 500, 108 | } 109 | } 110 | 111 | // Send verification email 112 | err = verifyemail.SendVerifyEmail(newUser.Greeting(), newUser.Email, verificationCode) 113 | if err != nil { 114 | s.logger.Warning(ctx, "failed to send verify email", err) 115 | return "", false, &common.Error{ 116 | StatusCode: 500, 117 | } 118 | } 119 | 120 | // Sign in user 121 | return s.signIn(ctx, true, userAgent, currentIP, emailTrimmed, body.Password) 122 | } 123 | 124 | func (s *Services) isValidPassword(ctx context.Context, password string) bool { 125 | // TODO - Change to return an error instead with the problem 126 | if len(password) < 8 { 127 | s.logger.Warning(ctx, "password is less than 8 characters", errors.New("error: invalid password")) 128 | // Length is less than 8 129 | return false 130 | } 131 | specialChar := 0 132 | for _, char := range password { 133 | if !s.isLetter(string(char)) { 134 | specialChar++ 135 | } 136 | 137 | if specialChar >= 5 { 138 | break 139 | } 140 | } 141 | 142 | if specialChar < 5 { 143 | s.logger.Warning(ctx, "password is has less than 5 special characters", errors.New("error: invalid password")) 144 | // Requires at least 5 non-letters 145 | return false 146 | } 147 | 148 | return true 149 | } 150 | 151 | func (s *Services) isLetter(password string) bool { 152 | for _, c := range password { 153 | if !unicode.IsLetter(c) { 154 | return false 155 | } 156 | } 157 | return true 158 | } 159 | 160 | func (s *Services) getEncryptedPassword(password string) (string, error) { 161 | bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) 162 | if err != nil { 163 | return "", err 164 | } 165 | return string(bytes), err 166 | } 167 | 168 | func (s *Services) SignIn(ctx context.Context, userAgent *user_agent.UserAgent, currentIP string, body SignInBody) (string, bool, *common.Error) { 169 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "SignIn") 170 | 171 | emailLowerCase := strings.ToLower(body.Email) 172 | emailTrimmed := strings.Trim(emailLowerCase, " ") 173 | return s.signIn(ctx, false, userAgent, currentIP, emailTrimmed, body.Password) 174 | } 175 | 176 | func (s *Services) signIn(ctx context.Context, isSignUp bool, userAgent *user_agent.UserAgent, currentIP string, email string, password string) (string, bool, *common.Error) { 177 | ctx = context.WithValue(ctx, logging.CtxHelpMethods, logging.AddToHelperMethods(ctx, "signIn")) 178 | 179 | // Grab user 180 | found, user, err := s.userRepository.GetUserByEmail(email) 181 | if err != nil { 182 | s.logger.Warning(ctx, "failed to get user by email", err) 183 | return "", false, &common.Error{ 184 | StatusCode: 500, 185 | } 186 | } 187 | 188 | if !found { 189 | s.logger.Warning(ctx, "failed to find user", errors.New("error: unauthorized")) 190 | return "", false, &common.Error{ 191 | StatusCode: 403, 192 | } 193 | } 194 | 195 | if !s.isUsersPassword(user.Password, password) { 196 | s.logger.Warning(ctx, "invalid password", errors.New("error: unauthorized")) 197 | return "", false, &common.Error{ 198 | StatusCode: 403, 199 | } 200 | } 201 | 202 | // They now have a valid signed in 203 | if user.AccountLocked { 204 | return "", false, &common.Error{ 205 | StatusCode: 403, 206 | Message: "error: Account has been locked. Please reset password.", 207 | } 208 | } 209 | 210 | // Check if trusted ip, level of legitamacy of the sign in 211 | lockSession, invalidSession, err := s.validateSignIn(ctx, isSignUp, user, userAgent, currentIP) 212 | if err != nil { 213 | return "", false, &common.Error{ 214 | StatusCode: 500, 215 | } 216 | } 217 | 218 | if invalidSession { 219 | err := s.lockUserAccount(user) 220 | if err != nil { 221 | // Ignore the failure but worth notifying your dev team for 222 | s.logger.Error(ctx, "failed to lock the user account", err) 223 | } 224 | return "", false, &common.Error{ 225 | StatusCode: 403, 226 | } 227 | } 228 | 229 | // Create session 230 | now := time.Now() 231 | expiryDate := now.AddDate(0, 0, 1) 232 | newSession := Session{ 233 | Email: email, 234 | Created: now, 235 | Expiry: expiryDate, 236 | Locked: lockSession, 237 | UnlockCode: uuid.New().String(), 238 | } 239 | 240 | // Save the session 241 | token, err := s.userRepository.SaveSession(newSession) 242 | if err != nil { 243 | s.logger.Warning(ctx, "failed to save session", err) 244 | return "", false, &common.Error{ 245 | StatusCode: 500, 246 | } 247 | } 248 | 249 | if lockSession { 250 | // Session has been locked. Send the user an email with a code to unlock it. 251 | s.logger.Info(ctx, "Session has been marked as locked, sending an email with the unlock code") 252 | err = sessionunlockemail.SendSessionUnLockEmail(user.Greeting(), user.Email, newSession.UnlockCode) 253 | if err != nil { 254 | s.logger.Warning(ctx, "failed to send session unlock email", err) 255 | return "", false, &common.Error{ 256 | StatusCode: 500, 257 | } 258 | } 259 | } else { 260 | // Send sign in email (General sign in and not locked accounts) 261 | err = signinemail.SendSignInEmail("there", email, currentIP, "", "") // TODO - Add in the actual values 262 | if err != nil { 263 | // Ignore the failure. This is my decision since it doesnt stop the user from signing 264 | // into their account. However, sending a sign in email is another layer of security. 265 | // You can return a 500 here if you want to make sure the email goes through. 266 | s.logger.Warning(ctx, "failed to send sign in email", err) 267 | } 268 | s.logger.Info(ctx, "Sent a sign in email") 269 | 270 | // Session is both valid and not locked. This is a trusted session. We should add the IP 271 | // to the list of trust IPs. At a minimum, the session they signed up with will have its 272 | // IP address stored as trusted. 273 | s.logger.Info(ctx, "Session is valid and trusted, adding to list of trusted IPs") 274 | err = s.userRepository.UpdateOrAddTrustedIPToUser(user.Email, IP{Address: currentIP, LocationFound: false}) 275 | if err != nil { 276 | s.logger.Warning(ctx, "failed to save new trusted IP", err) 277 | } 278 | } 279 | 280 | return token, lockSession, nil 281 | } 282 | 283 | func (s *Services) isUsersPassword(storedPasswordHash string, plainTextInputtedPassword string) bool { 284 | return bcrypt.CompareHashAndPassword([]byte(storedPasswordHash), []byte(plainTextInputtedPassword)) == nil 285 | } 286 | 287 | func (s *Services) validateSignIn(ctx context.Context, isSignUp bool, user User, userAgent *user_agent.UserAgent, currentIP string) (bool, bool, error) { 288 | ctx = context.WithValue(ctx, logging.CtxHelpMethods, logging.AddToHelperMethods(ctx, "validateSignIn")) 289 | 290 | if isSignUp { 291 | // User has just signed up so nothing to compare against. 292 | s.logger.Info(ctx, "User has just signed up, account is unlock and valid session") 293 | return false, false, nil 294 | } 295 | 296 | SESSION_LOCKED_LIMIT := 2 297 | warnings := 0 298 | // Check if IP is in trusted IPs 299 | if user.HasTrustedIP(currentIP) { 300 | // Log valid IP 301 | s.logger.Info(ctx, "User has a trusted IP") 302 | } else if user.HasInvalidIPs(currentIP) { 303 | // Current sign in is using an invalid IP 304 | warnings += 1 305 | s.logger.Info(ctx, "User has an invalid IP") 306 | } else { 307 | // Lock the session 308 | warnings += SESSION_LOCKED_LIMIT // Brand new IP 309 | s.logger.Info(ctx, "User has brand new IP") 310 | } 311 | 312 | // Check if bot 313 | if userAgent.Bot() { 314 | // Lock the session 315 | warnings += SESSION_LOCKED_LIMIT 316 | s.logger.Info(ctx, "User is a bot based on User Agent Header") 317 | } 318 | 319 | // Gather information about past devices compared to this one 320 | browserName, browserVersion := userAgent.Browser() 321 | s.logger.Info(ctx, fmt.Sprintf("User is currently on browser : %s %s", browserName, browserVersion)) 322 | s.logger.Info(ctx, fmt.Sprintf("User is currently on OS : %s", userAgent.OS())) 323 | 324 | browserTypeFound := false 325 | browserVersionFound := false 326 | osFound := false 327 | for _, device := range user.KnownDevices { 328 | if device.ValidDevice && device.Browser == browserName && device.BrowserVersion == browserVersion { 329 | browserTypeFound = true 330 | browserVersionFound = true 331 | } else if device.ValidDevice && device.Browser == browserName { 332 | browserTypeFound = true 333 | } 334 | if device.ValidDevice && userAgent.OS() == device.OperatingSystem { 335 | osFound = true 336 | } 337 | } 338 | 339 | // Check if same browser 340 | if !browserTypeFound { 341 | // Lock the session 342 | warnings += SESSION_LOCKED_LIMIT 343 | s.logger.Info(ctx, "User has changed browsers as a previous session on User Agent Header") 344 | } else if browserTypeFound && !browserVersionFound { 345 | warnings += 1 346 | s.logger.Info(ctx, "User is on same browser (not version) as a previous session on User Agent Header") 347 | } else { 348 | s.logger.Info(ctx, "User is on same browser and version as a previous session on User Agent Header") 349 | } 350 | 351 | // Check if OS changed 352 | if !osFound { 353 | // Lock the session 354 | warnings += SESSION_LOCKED_LIMIT 355 | s.logger.Info(ctx, "User has changed OS's as a previous session on User Agent Header") 356 | } else { 357 | s.logger.Info(ctx, "User is on OS as a previous session on User Agent Header") 358 | } 359 | 360 | // You can add more details here such as location (Ex. country changed etc.) 361 | 362 | if warnings >= SESSION_LOCKED_LIMIT { 363 | // Lock the session and ask for secondary validation 364 | // One warning like old trusted IP is fine. 365 | s.logger.Info(ctx, "Too many warnings, locking session") 366 | return true, false, nil 367 | } 368 | // Do nothing 369 | return false, false, nil 370 | } 371 | 372 | func (s *Services) lockUserAccount(user User) error { 373 | err := accountlockemail.SendAccountLockedEmail("there", user.Email) 374 | if err != nil { 375 | return err 376 | } 377 | return s.userRepository.UpdateAccountLocked(user.Email, true) 378 | } 379 | 380 | func (s *Services) LogOut(ctx context.Context, authToken string) *common.Error { 381 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "LogOut") 382 | 383 | // Grab session 384 | found, _, err := s.userRepository.GetSessionById(authToken) 385 | if err != nil { 386 | s.logger.Warning(ctx, "failed to get session", err) 387 | return &common.Error{ 388 | StatusCode: 500, 389 | } 390 | } 391 | if !found { 392 | s.logger.Warning(ctx, "session not found", errors.New("error: not found")) 393 | return &common.Error{ 394 | StatusCode: 403, 395 | } 396 | } 397 | 398 | // Mark as expired 399 | err = s.userRepository.MarkSessionAsExpired(authToken) 400 | if err != nil { 401 | s.logger.Warning(ctx, "failed to mark session as expired", err) 402 | return &common.Error{ 403 | StatusCode: 500, 404 | } 405 | } 406 | 407 | return nil 408 | } 409 | 410 | // UnlockSession removes the lock flag from the session to allow to continue to make requests. 411 | func (s *Services) UnlockSession(ctx context.Context, currentIP string, authToken string, body UnlockSessionBody) *common.Error { 412 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "UnlockSession") 413 | found, session, err := s.userRepository.GetSessionById(authToken) 414 | if err != nil { 415 | // TODO - Test not found 416 | s.logger.Warning(ctx, "failed to get the session by ID", err) 417 | return &common.Error{ 418 | StatusCode: 500, 419 | } 420 | } 421 | if !found { 422 | s.logger.Warning(ctx, "failed to find session", errors.New("not found")) 423 | return &common.Error{ 424 | StatusCode: 403, 425 | } 426 | } 427 | 428 | if !session.Locked { 429 | s.logger.Info(ctx, "Session was not locked") 430 | return nil 431 | } 432 | 433 | // Compare the code provided against code on session 434 | if session.UnlockCode == body.Code { 435 | // Valid code, update session 436 | err := s.userRepository.UnlockSession(authToken) 437 | if err != nil { 438 | s.logger.Warning(ctx, "failed to update the session to be unlocked", err) 439 | return &common.Error{ 440 | StatusCode: 500, 441 | } 442 | } 443 | 444 | return nil 445 | } 446 | return &common.Error{ 447 | StatusCode: 403, 448 | } 449 | } 450 | 451 | // SendForgotPassword sends a forgot password code via email to the user to reset their link. Since this is an 452 | // unprotected endpoint (not auth) and we are calling a third party service (Sendgrid). We throttle the number of 453 | // forgot password requests by a particular IP. 25 in 24 hours. There can be exceptions but this is on a case by 454 | // case basis. I'd imagine places like universities would break this. 455 | func (s *Services) SendForgotPassword(ctx context.Context, currentIP string, body SendForgotPasswordBody) *common.Error { 456 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "SendForgotPassword") 457 | if body.Email == "" { 458 | s.logger.Warning(ctx, "Email is missing", errors.New("error: email required")) 459 | return &common.Error{ 460 | StatusCode: 400, 461 | Message: "Email is required", 462 | } 463 | } 464 | 465 | // Verify email exists 466 | userExists, user, err := s.userRepository.GetUserByEmail(body.GetFormattedEmail()) 467 | if err != nil { 468 | s.logger.Warning(ctx, "failed to look up user", err) 469 | return &common.Error{ 470 | StatusCode: 500, 471 | } 472 | } 473 | if !userExists { 474 | s.logger.Warning(ctx, "user does not exists", errors.New("not found")) 475 | return &common.Error{ 476 | StatusCode: 200, 477 | Message: "Forgot password email sent", 478 | } 479 | } 480 | 481 | // Create instance 482 | code := uuid.New().String() 483 | now := time.Now() 484 | expiry := now.AddDate(0, 0, 1) 485 | err = s.forgotPasswordRepository.Save(ForgotPasswordCode{ 486 | Email: body.GetFormattedEmail(), 487 | Code: code, 488 | Created: now, 489 | Expiry: expiry, 490 | }) 491 | if err != nil { 492 | s.logger.Warning(ctx, "failed to save forgot password", err) 493 | return &common.Error{ 494 | StatusCode: 500, 495 | } 496 | } 497 | 498 | // Send email 499 | err = forgotpasswordemail.SendForgotPasswordEmail(user.Greeting(), body.GetFormattedEmail(), code) 500 | if err != nil { 501 | s.logger.Warning(ctx, "failed to send the forgot password email", err) 502 | return &common.Error{ 503 | StatusCode: 500, 504 | } 505 | } 506 | 507 | return nil 508 | } 509 | 510 | func (s *Services) ForgotPassword(ctx context.Context, currentIP string, body ResetForgotPasswordBody) *common.Error { 511 | ctx = context.WithValue(ctx, logging.CtxServiceMethod, "ForgotPassword") 512 | 513 | // Validate all fields 514 | if body.GetFormattedEmail() == "" { 515 | s.logger.Warning(ctx, "email is required", errors.New("error: invalid request")) 516 | return &common.Error{ 517 | StatusCode: 400, 518 | Message: "Email is required", 519 | } 520 | } 521 | if body.Code == "" { 522 | s.logger.Warning(ctx, "email is required", errors.New("error: invalid request")) 523 | return &common.Error{ 524 | StatusCode: 400, 525 | Message: "Code is required", 526 | } 527 | } 528 | 529 | // TODO - Add some throttling 530 | 531 | // Check if forgot password code exists 532 | exists, err := s.forgotPasswordRepository.Exists(body.GetFormattedEmail(), body.Code) 533 | if err != nil { 534 | s.logger.Warning(ctx, "failed to check if forgot password exists", err) 535 | return &common.Error{ 536 | StatusCode: 500, 537 | } 538 | } 539 | if !exists { 540 | s.logger.Warning(ctx, "invalid email + code combination for forgot password", errors.New("unauthorized")) 541 | return &common.Error{ 542 | StatusCode: 403, 543 | } 544 | } 545 | 546 | // Validate password strength 547 | if !s.isValidPassword(ctx, body.NewPassword) { 548 | s.logger.Error(ctx, "password does not meet requirements", errors.New("invalid password")) 549 | return &common.Error{ 550 | StatusCode: 400, 551 | Message: "Password does not meet requirements", 552 | } 553 | } 554 | 555 | // Update password 556 | hash, err := s.getEncryptedPassword(body.NewPassword) 557 | if err != nil { 558 | s.logger.Warning(ctx, "failed to hash password", err) 559 | return &common.Error{ 560 | StatusCode: 500, 561 | } 562 | } 563 | err = s.userRepository.UpdatePassword(body.GetFormattedEmail(), hash) 564 | if err != nil { 565 | s.logger.Warning(ctx, "failed to update password", err) 566 | return &common.Error{ 567 | StatusCode: 500, 568 | } 569 | } 570 | 571 | // Update forgot password code 572 | err = s.forgotPasswordRepository.MarkCodeAsComplete(body.GetFormattedEmail(), body.Code) 573 | if err != nil { 574 | s.logger.Error(ctx, "failed to update mark code as completed", err) 575 | // Not returning error for UX 576 | } 577 | 578 | return nil 579 | } --------------------------------------------------------------------------------