Skip to content
Merged
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,53 @@

[![codecov](https://codecov.io/gh/ClassConnect-org/users-microservice/graph/badge.svg?token=I5YHR5YBCF)](https://codecov.io/gh/ClassConnect-org/users-microservice)

### Test Coverage Grid
## Descripcion

![coverage grid](https://codecov.io/gh/ClassConnect-org/users-microservice/graphs/tree.svg?token=I5YHR5YBCF)
Este microservicio tiene como responsabilidad el manejo de autenticacion de usuarios,
ya sea creacion, login, validacion de email y demas datos,
el manejo de sesiones utilizando stateless sessions con jwt tokens necesarias para el resto
de los microservicios y el manejo de los perfiles incluyendo ediciones, almacenamiento de imagenes y proteccion de datos privados.

## Endpoints

Los endpoints de este microservicio se pueden encontrar en el *swagger* del mismo (o alternativamente en la carpeta `docs/`),
los mismos son:

* `GET` **/auth** (Validar sesion actual)
* `GET` **/auth/activate/:id?token=string&refresh=bool** (Activacion de cuenta)
* `POST` **/auth/google** (Inicio de sesion/Creacion de cuenta con Google)
* `POST` **/auth/login** (Inicio de sesion con email y password)
* `POST` **/signup** (Creacion de cuenta con email y password)
* `GET` **/profile** (Obtener perfil de la sesion actual)
* `PATCH` **/profile** (Editar perfil de la sesion actual)
* `PUT` **/profile/picture** (Subir imagen como foto de perfil)
* `DELETE` **/profile/picture** (Borrar foto de perfil)
* `GET` **/profile/:id** (Obtener perfil de un usuario dado)

## Estructura

Se utiliza la arquitectura package by layer, donde los controladores se pueden encontrar en la
carpeta *handlers*, los servicios en *services* y los repositorios en *repository*.

Para cada grupo de rutas hay una interfaz para el Handler y otra para el Service, sin embargo
solamente hay dos interfaces diferentes para repositorios (una de users repository y otra de profiles repository).

## Desarrollo local

### Docker-compose de uso local

```bash
docker compose -f local-dev-compose.yml up --build
```

### Migraciones

```bash
migrate create -ext sql -dir migrations -seq create_assignments_table
```

## Despliegue

### Pipelines

### Como desplegar a produccion
7 changes: 7 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"strconv"
"time"
)

type ServerEnvironment int
Expand All @@ -28,6 +29,9 @@ type Config struct {
SUPABASE_KEY string
MAX_FILE_UPLOAD_SIZE int64

MAX_LOGIN_RETRIES int
BLOCK_DURATION time.Duration

// Notifications service
NOTIFICATIONS_SERVICE string

Expand All @@ -53,6 +57,9 @@ func LoadConfig() *Config {
SUPABASE_KEY: getEnvOrDefault("SUPABASE_KEY", ""),
MAX_FILE_UPLOAD_SIZE: getMaxFileSizeOrDefault("MAX_FILE_SIZE", 10*1024*1024), // 10MB

MAX_LOGIN_RETRIES: 5,
BLOCK_DURATION: time.Second * 10,

NOTIFICATIONS_SERVICE: "http://" + getEnvOrDefault("NOTIFICATIONS_SERVICE", "") + ":8000",

DD_SERVICE_NAME: getEnvOrDefault("DD_SERVICE_NAME", "users-microservice"),
Expand Down
12 changes: 12 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ const docTemplate = `{
],
"summary": "Google Login \u0026 Signup",
"parameters": [
{
"type": "boolean",
"description": "Confirm account merge (true/false) - Optional, defaults to false",
"name": "confirm_account_merge",
"in": "query"
},
{
"description": "Google login data",
"name": "request",
Expand Down Expand Up @@ -151,6 +157,12 @@ const docTemplate = `{
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"409": {
"description": "Email is already linked to a non-google account, confirm account merge",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@
],
"summary": "Google Login \u0026 Signup",
"parameters": [
{
"type": "boolean",
"description": "Confirm account merge (true/false) - Optional, defaults to false",
"name": "confirm_account_merge",
"in": "query"
},
{
"description": "Google login data",
"name": "request",
Expand Down Expand Up @@ -144,6 +150,12 @@
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
},
"409": {
"description": "Email is already linked to a non-google account, confirm account merge",
"schema": {
"$ref": "#/definitions/dto.ErrorResponse"
}
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ paths:
description: Login a user with Google authentication or Create it if it doesn't
exist
parameters:
- description: Confirm account merge (true/false) - Optional, defaults to false
in: query
name: confirm_account_merge
type: boolean
- description: Google login data
in: body
name: request
Expand All @@ -210,6 +214,11 @@ paths:
description: Invalid credentials or invalid JWT token
schema:
$ref: '#/definitions/dto.ErrorResponse'
"409":
description: Email is already linked to a non-google account, confirm account
merge
schema:
$ref: '#/definitions/dto.ErrorResponse'
summary: Google Login & Signup
tags:
- users
Expand Down
3 changes: 3 additions & 0 deletions internal/handlers/users/auth/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@ type AuthHandler interface {

// RecoverPassword handles the password change process for a user or token refresh.
RecoverPassword(c *gin.Context)

// UpdateUserBlockStatus updates the user's block status based on the provided ID and block status.
UpdateUserBlockStatus(c *gin.Context)
}
10 changes: 9 additions & 1 deletion internal/handlers/users/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,13 @@
// @Tags users
// @Accept json
// @Produce json
// @Param confirm_account_merge query boolean false "Confirm account merge (true/false) - Optional, defaults to false"
// @Param request body dto.GoogleAuthDTO true "Google login data"
// @Success 200 {object} dto.UserResponseDTO "Successful login (Wrapped in data envelope)"
// @Success 201 {object} dto.UserResponseDTO "User created (Wrapped in data envelope)"
// @Failure 400 {object} dto.ErrorResponse "Bad request body"
// @Failure 401 {object} dto.ErrorResponse "Invalid credentials or invalid JWT token"
// @Failure 409 {object} dto.ErrorResponse "Email is already linked to a non-google account, confirm account merge"
// @Router /auth/google [post]
func (h *authHandler) GoogleLogin(c *gin.Context) {
if c.GetBool("includedJWT") {
Expand All @@ -101,8 +103,10 @@
return
}

confirmAccountMerge := c.Query("confirm_account_merge") == "true"

created := false
user, err := h.service.GoogleLogin(googleLoginInfo.IdToken, googleLoginInfo.Type, &created)
user, err := h.service.GoogleLogin(googleLoginInfo.IdToken, googleLoginInfo.Type, &created, confirmAccountMerge)
if err != nil {
_ = c.Error(err)
return
Expand Down Expand Up @@ -260,6 +264,10 @@
handlers.HandleBodilessResponse(c, http.StatusNoContent)
}

func (h *authHandler) UpdateUserBlockStatus(c *gin.Context) {
// TODO: Implement the logic to update user block status by admins

Check warning on line 268 in internal/handlers/users/auth/login.go

View check run for this annotation

Codecov / codecov/patch

internal/handlers/users/auth/login.go#L267-L268

Added lines #L267 - L268 were not covered by tests
}

/* Helper Functions */

// handleValidSession checks if the JWT token is valid and if the user session is valid.
Expand Down
4 changes: 3 additions & 1 deletion internal/handlers/users/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ func TestAccountActivation_RefreshToken(t *testing.T) {
assert.NotContains(t, w.Header().Get("Authorization"), "Bearer")
}

func TestAccountActivation(t *testing.T) {
func TestPasswordRecovery(t *testing.T) {
user, _ := repo.GetUserByEmail("test2@email.com")

pwRecoveryDTO := &dto.PasswordRecoveryDTO{
Expand All @@ -321,6 +321,8 @@ func TestAccountActivation(t *testing.T) {
assert.Equal(t, http.StatusNoContent, w.Code, "should return 204 if refresh is successful")

// invalid token
pwRecoveryDTO.Password = "newpassword123"
body, _ = json.Marshal(pwRecoveryDTO)
req, _ = http.NewRequest("POST", "/auth/recovery?token=invalid-token", bytes.NewBuffer(body))
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
Expand Down
15 changes: 15 additions & 0 deletions internal/models/blocks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package models

import (
"time"

"github.com/google/uuid"
)

// Block represents a user block in the database.
type Block struct {
UserID uuid.UUID `gorm:"type:uuid;not null;primaryKey"`
BlockedUntil time.Time `gorm:"not null"`
CreatedAt time.Time `gorm:"not null;default:current_timestamp"`
UpdatedAt time.Time `gorm:"not null;default:current_timestamp"`
}
11 changes: 6 additions & 5 deletions internal/models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import (

// User represents the user table in the database.
type User struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"`
Name string `gorm:"not null"`
Email string `gorm:"unique;not null"`
Password string `gorm:"not null"`
Active bool `gorm:"default:false"`
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"`
Name string `gorm:"not null"`
Email string `gorm:"unique;not null"`
Password string `gorm:"not null"`
Active bool `gorm:"default:false"`
LoginRetries int `gorm:"default:0"`
}
67 changes: 67 additions & 0 deletions internal/repository/memory/users.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package memory

import (
"time"
"users-microservice/internal/errors"
"users-microservice/internal/models"
"users-microservice/internal/repository/users"
Expand All @@ -11,12 +12,14 @@ import (
type inMemoryUsersRepository struct {
usersDB map[uuid.UUID]models.User
googleSubsDB map[uuid.UUID]string
blocksDB map[uuid.UUID]time.Time
}

func NewInMemoryUsersRepository() users.UsersRepository {
return &inMemoryUsersRepository{
usersDB: make(map[uuid.UUID]models.User),
googleSubsDB: make(map[uuid.UUID]string),
blocksDB: make(map[uuid.UUID]time.Time),
}
}

Expand Down Expand Up @@ -48,6 +51,21 @@ func (r *inMemoryUsersRepository) CreateGoogleUser(user *models.User, _picture,
return nil
}

func (r *inMemoryUsersRepository) MergeGoogleUser(userID uuid.UUID, name, picture, sub string) (*models.User, error) {
user, exists := r.usersDB[userID]
if !exists {
return nil, errors.NewNotFoundError("user not found")
}

user.Name = name
user.Active = true
r.usersDB[userID] = user

r.googleSubsDB[userID] = sub

return &user, nil
}

/* READ OPERATIONS */

func (r *inMemoryUsersRepository) GetUserByID(id uuid.UUID) (*models.User, error) {
Expand Down Expand Up @@ -123,3 +141,52 @@ func (r *inMemoryUsersRepository) ChangePassword(id uuid.UUID, newPassword strin

return nil
}

func (r *inMemoryUsersRepository) BlockUser(id uuid.UUID, blockUntil time.Time) error {
_, exists := r.usersDB[id]
if !exists {
return errors.NewNotFoundError("user not found")
}
r.blocksDB[id] = blockUntil

return nil
}

func (r *inMemoryUsersRepository) UnblockUser(id uuid.UUID) error {
_, exists := r.usersDB[id]
if !exists {
return errors.NewNotFoundError("user not found")
}

if _, blocked := r.blocksDB[id]; !blocked {
return errors.NewNotFoundError("user is not blocked")
}

delete(r.blocksDB, id)
return nil
}

func (r *inMemoryUsersRepository) IsUserBlocked(id uuid.UUID) (bool, error) {
_, exists := r.usersDB[id]
if !exists {
return false, errors.NewNotFoundError("user not found")
}
blockedUntil, blocked := r.blocksDB[id]
if blocked && blockedUntil.After(time.Now()) {
return true, nil
}

return false, nil
}

func (r *inMemoryUsersRepository) UpdateLoginRetries(id uuid.UUID, retries int) error {
user, exists := r.usersDB[id]
if !exists {
return errors.NewNotFoundError("user not found")
}

user.LoginRetries = retries
r.usersDB[id] = user

return nil
}
Loading