Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .env.testing
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
ENV="testing"
JWT_SECRET=HyMlUMZSbWLm65lURmMISWWHHK6K6dgH
ACCESS_TOKEN_KEY=access_token_key
JWT_SECRET=FtSBkAoc1UbijMIRAioj1fiQMeP4uBx2
ACCESS_TOKEN_KEY=_gc_9hp1b73cGDCmAPgaVTYOlS6cjPsnDYho
JWT_TTL=15
IS_AUTH_KEY=auth_key
HTTP_SECURE=false
HTTP_ONLY=false
HTTP_DOMAIN=localhost
PORT=8080
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_NAME=test_db
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,25 @@ env:
DOCKER_HUB_PAT: ${{ secrets.DOCKER_HUB_PAT }}
DOCKER_CONTAINER: gcstatus-api
DOCKER_PORT: ${{ secrets.DOCKER_PORT }}
DB_NAME: test_db
DB_HOST: 127.0.0.1

jobs:
tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:latest
env:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: ${{ env.DB_NAME }}
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=10s
--health-timeout=5s
--health-retries=3
ports:
- 3306:3306
strategy:
fail-fast: true
matrix:
Expand Down Expand Up @@ -46,6 +61,13 @@ jobs:
go mod download
go mod vendor

- name: Wait for MySQL to be ready
run: |
until mysqladmin ping -h ${{ env.DB_HOST }} --silent; do
echo "Waiting for MySQL..."
sleep 1
done

- name: Execute tests
run: go test ./tests/... -coverprofile=coverage.txt

Expand Down
2 changes: 1 addition & 1 deletion di/di.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func InitDependencies() (
adminTagService,
adminGameService,
heartService,
commentService := Setup(dbConn)
commentService := Setup(dbConn, *cfg)

// Setup clients for non-test environment
if cfg.ENV != "testing" {
Expand Down
18 changes: 11 additions & 7 deletions di/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ import (
)

func MigrateModels(dbConn *gorm.DB) {
models := []any{
models := GetModels()

for _, model := range models {
if err := dbConn.AutoMigrate(model); err != nil {
log.Fatalf("Failed to migrate model %T: %v", model, err)
}
}
}

func GetModels() []any {
return []any{
&domain.Reward{},
&domain.Level{},
&domain.Wallet{},
Expand Down Expand Up @@ -70,10 +80,4 @@ func MigrateModels(dbConn *gorm.DB) {
&domain.Roleable{},
&domain.Permissionable{},
}

for _, model := range models {
if err := dbConn.AutoMigrate(model); err != nil {
log.Fatalf("Failed to migrate model %T: %v", model, err)
}
}
}
8 changes: 6 additions & 2 deletions di/setup_dependencies.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package di

import (
"gcstatus/config"
"gcstatus/internal/adapters/db"
db_admin "gcstatus/internal/adapters/db/admin"
"gcstatus/internal/usecases"
Expand All @@ -9,7 +10,7 @@ import (
"gorm.io/gorm"
)

func Setup(dbConn *gorm.DB) (
func Setup(dbConn *gorm.DB, env config.Config) (
*usecases.UserService,
*usecases.AuthService,
*usecases.PasswordResetService,
Expand Down Expand Up @@ -54,7 +55,7 @@ func Setup(dbConn *gorm.DB) (

// Create service instances
userService := usecases.NewUserService(userRepo)
authService := usecases.NewAuthService(nil)
authService := usecases.NewAuthService(env, nil)
passwordResetService := usecases.NewPasswordResetService(passwordResetRepo)
levelService := usecases.NewLevelService(levelRepo)
profileService := usecases.NewProfileService(profileRepo)
Expand All @@ -74,6 +75,9 @@ func Setup(dbConn *gorm.DB) (
heartService := usecases.NewHeartService(heartRepo)
commentService := usecases.NewCommentService(commentRepo)

// Set dependencies that require service instances to avoid circular references
authService.SetUserService(userService)

return userService,
authService,
passwordResetService,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ require (
golang.org/x/arch v0.10.0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/text v0.19.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
Expand Down
193 changes: 27 additions & 166 deletions internal/adapters/api/auth_handler.go
Original file line number Diff line number Diff line change
@@ -1,216 +1,77 @@
package api

import (
"errors"
"fmt"
"gcstatus/config"
"gcstatus/internal/domain"
"gcstatus/internal/resources"
"gcstatus/internal/usecases"
"gcstatus/internal/utils"
"gcstatus/pkg/s3"
"net/http"
"time"

"github.com/gin-gonic/gin"
"gorm.io/gorm"
)

type AuthHandler struct {
authService *usecases.AuthService
userService *usecases.UserService
}

func NewAuthHandler(authService *usecases.AuthService, userService *usecases.UserService) *AuthHandler {
return &AuthHandler{authService: authService, userService: userService}
func NewAuthHandler(
authService *usecases.AuthService,
userService *usecases.UserService,
) *AuthHandler {
return &AuthHandler{
authService: authService,
userService: userService,
}
}

func (h *AuthHandler) Login(c *gin.Context) {
var loginData struct {
Identifier string `json:"identifier" binding:"required"`
Password string `json:"password" binding:"required"`
}

env := config.LoadConfig()

if err := c.ShouldBindJSON(&loginData); err != nil {
RespondWithError(c, http.StatusBadRequest, "Please provide valid credentials.")
return
}

user, err := h.userService.AuthenticateUser(loginData.Identifier, loginData.Password)
if err != nil {
RespondWithError(c, http.StatusUnauthorized, "Invalid credentials. Please try again.")
return
}

if user.Blocked {
RespondWithError(c, http.StatusForbidden, "You are blocked on GCStatus platform. If you think this is an error, please, contact support!")
return
}

expirationSeconds, err := h.authService.GetExpirationSeconds(env.JwtTtl)
if err != nil {
RespondWithError(c, http.StatusInternalServerError, "Could not parse token expiration.")
return
}

httpSecure, httpOnly, err := h.authService.GetCookieSettings(env.HttpSecure, env.HttpOnly)
if err != nil {
RespondWithError(c, http.StatusInternalServerError, "Could not parse cookie settings.")
return
}
var request usecases.LoginPayload

tokenString, err := h.authService.CreateJWTToken(user.ID, expirationSeconds)
if err != nil {
RespondWithError(c, http.StatusInternalServerError, "Could not create token.")
if err := c.ShouldBindJSON(&request); err != nil {
RespondWithError(c, http.StatusBadRequest, "Please provide a valid data.")
return
}

encryptedToken, err := h.authService.EncryptToken(tokenString, env.JwtSecret)
response, err := h.authService.Login(c, request)
if err != nil {
RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Encryption error: %v", err))
RespondWithError(c, err.Code, err.Error())
return
}

h.authService.SetAuthCookies(c, env.AccessTokenKey, encryptedToken, env.IsAuthKey, expirationSeconds, httpSecure, httpOnly, env.Domain)

c.JSON(http.StatusOK, resources.Response{
Data: gin.H{"message": "Logged in successfully"},
})
c.JSON(http.StatusOK, response)
}

func (h *AuthHandler) Register(c *gin.Context) {
var registrationData struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Nickname string `json:"nickname" binding:"required"`
Birthdate string `json:"birthdate" binding:"required"`
Password string `json:"password" binding:"required"`
PasswordConfirmation string `json:"password_confirmation" binding:"required"`
}
var request usecases.RegisterPayload

if err := c.ShouldBindJSON(&registrationData); err != nil {
if err := c.ShouldBindJSON(&request); err != nil {
RespondWithError(c, http.StatusBadRequest, "Please, provide a valid data to proceed.")
return
}

if registrationData.Password != registrationData.PasswordConfirmation {
RespondWithError(c, http.StatusBadRequest, "Password confirmation does not match.")
return
}

birthdate, err := time.Parse("2006-01-02", registrationData.Birthdate)
response, err := h.authService.Register(c, request)
if err != nil {
RespondWithError(c, http.StatusBadRequest, "Invalid birthdate format.")
RespondWithError(c, err.Code, err.Error())
return
}

if time.Since(birthdate).Hours() < 14*365*24 {
RespondWithError(c, http.StatusBadRequest, "You must be at least 14 years old to register.")
return
}

if !utils.ValidatePassword(registrationData.Password) {
RespondWithError(c, http.StatusBadRequest, "Password must be at least 8 characters long and include a lowercase letter, an uppercase letter, a number, and a symbol.")
return
}

existingUserByEmail, err := h.userService.FindUserByEmailOrNickname(registrationData.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
RespondWithError(c, http.StatusInternalServerError, err.Error())
return
}

if existingUserByEmail != nil {
RespondWithError(c, http.StatusConflict, "Email already in use.")
return
}

existingUserByNickname, err := h.userService.FindUserByEmailOrNickname(registrationData.Nickname)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
RespondWithError(c, http.StatusInternalServerError, err.Error())
return
}

if existingUserByNickname != nil {
RespondWithError(c, http.StatusConflict, "Nickname already in use.")
return
}

hashedPassword, err := utils.HashPassword(registrationData.Password)
if err != nil {
RespondWithError(c, http.StatusInternalServerError, "Error hashing password.")
return
}

user := domain.User{
Name: registrationData.Name,
Email: registrationData.Email,
Nickname: registrationData.Nickname,
Birthdate: birthdate,
Password: string(hashedPassword),
LevelID: 1,
Profile: domain.Profile{Share: false},
Wallet: domain.Wallet{Amount: 0},
}

if err := h.userService.CreateWithProfile(&user); err != nil {
RespondWithError(c, http.StatusInternalServerError, "Error creating user.")
return
}

env := config.LoadConfig()

expirationSeconds, err := h.authService.GetExpirationSeconds(env.JwtTtl)
if err != nil {
RespondWithError(c, http.StatusInternalServerError, "Could not parse token expiration.")
return
}

httpSecure, httpOnly, err := h.authService.GetCookieSettings(env.HttpSecure, env.HttpOnly)
if err != nil {
RespondWithError(c, http.StatusInternalServerError, "Could not parse cookie settings.")
return
}

tokenString, err := h.authService.CreateJWTToken(user.ID, expirationSeconds)
if err != nil {
RespondWithError(c, http.StatusInternalServerError, "Could not create token.")
return
}
c.JSON(http.StatusCreated, response)
}

encryptedToken, err := utils.Encrypt(tokenString, env.JwtSecret)
func (h *AuthHandler) Logout(c *gin.Context) {
response, err := h.authService.Logout(c)
if err != nil {
RespondWithError(c, http.StatusInternalServerError, fmt.Sprintf("Encryption error: %v", err))
RespondWithError(c, err.Code, err.Error())
return
}

h.authService.SetAuthCookies(c, env.AccessTokenKey, encryptedToken, env.IsAuthKey, expirationSeconds, httpSecure, httpOnly, env.Domain)

c.JSON(http.StatusOK, resources.Response{
Data: gin.H{"message": "User registered successfully"},
})
}

func (h *AuthHandler) Logout(c *gin.Context) {
env := config.LoadConfig()

h.authService.ClearAuthCookies(c, env.AccessTokenKey, env.IsAuthKey, env.Domain)

c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
c.JSON(http.StatusOK, response)
}

func (h *AuthHandler) Me(c *gin.Context) {
user, err := utils.Auth(c, h.userService.GetUserByID)
response, err := h.authService.Me(c)
if err != nil {
RespondWithError(c, http.StatusUnauthorized, err.Error())
RespondWithError(c, err.Code, err.Error())
return
}

transformedUser := resources.TransformUser(*user, s3.GlobalS3Client)

c.JSON(http.StatusOK, resources.Response{
Data: transformedUser,
})
c.JSON(http.StatusOK, response)
}
Loading