diff --git a/docs/docs.go b/docs/docs.go index 4d34e5a..c00116e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -44,6 +44,65 @@ const docTemplate = `{ } } }, + "/auth/activate/{id}": { + "get": { + "description": "Activate a user account with the activation token or refresh the token", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Account Activation", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Activation token", + "name": "token", + "in": "query" + }, + { + "type": "string", + "description": "Refresh activation token (true/false)", + "name": "refresh", + "in": "query" + } + ], + "responses": { + "200": { + "description": "User data (Wrapped in data envelope)", + "schema": { + "$ref": "#/definitions/dto.UserResponseDTO" + } + }, + "400": { + "description": "Bad request body", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "401": { + "description": "Invalid token or no param provided or account already active", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "403": { + "description": "Already logged in", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/auth/google": { "post": { "description": "Login a user with Google authentication or Create it if it doesn't exist", @@ -142,6 +201,61 @@ const docTemplate = `{ } } }, + "/auth/recovery": { + "post": { + "description": "Request a password recovery code or change the password with the code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Password Recovery", + "parameters": [ + { + "type": "string", + "description": "Password recovery token", + "name": "token", + "in": "query" + }, + { + "type": "string", + "description": "Refresh password recovery code (true/false)", + "name": "refresh", + "in": "query" + }, + { + "description": "Password recovery data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.PasswordRecoveryDTO" + } + } + ], + "responses": { + "204": { + "description": "Password recovery code sent or changed successfully" + }, + "400": { + "description": "Bad request body", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "401": { + "description": "Invalid token or no param provided", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/profile": { "get": { "description": "Get profile of the current session", @@ -193,7 +307,7 @@ const docTemplate = `{ "summary": "Patch profile", "parameters": [ { - "description": "Profile data (picture is ignored from request - use UploadPicture endpoint)", + "description": "Profile patch data", "name": "profile", "in": "body", "required": true, @@ -474,6 +588,20 @@ const docTemplate = `{ } } }, + "dto.PasswordRecoveryDTO": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, "dto.ProfilePatchDTO": { "type": "object", "properties": { @@ -552,10 +680,15 @@ const docTemplate = `{ "dto.UserResponseDTO": { "type": "object", "required": [ + "active", "email", + "id", "name" ], "properties": { + "active": { + "type": "boolean" + }, "email": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 2052074..dae6127 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -37,6 +37,65 @@ } } }, + "/auth/activate/{id}": { + "get": { + "description": "Activate a user account with the activation token or refresh the token", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Account Activation", + "parameters": [ + { + "type": "string", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Activation token", + "name": "token", + "in": "query" + }, + { + "type": "string", + "description": "Refresh activation token (true/false)", + "name": "refresh", + "in": "query" + } + ], + "responses": { + "200": { + "description": "User data (Wrapped in data envelope)", + "schema": { + "$ref": "#/definitions/dto.UserResponseDTO" + } + }, + "400": { + "description": "Bad request body", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "401": { + "description": "Invalid token or no param provided or account already active", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "403": { + "description": "Already logged in", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/auth/google": { "post": { "description": "Login a user with Google authentication or Create it if it doesn't exist", @@ -135,6 +194,61 @@ } } }, + "/auth/recovery": { + "post": { + "description": "Request a password recovery code or change the password with the code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Password Recovery", + "parameters": [ + { + "type": "string", + "description": "Password recovery token", + "name": "token", + "in": "query" + }, + { + "type": "string", + "description": "Refresh password recovery code (true/false)", + "name": "refresh", + "in": "query" + }, + { + "description": "Password recovery data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.PasswordRecoveryDTO" + } + } + ], + "responses": { + "204": { + "description": "Password recovery code sent or changed successfully" + }, + "400": { + "description": "Bad request body", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "401": { + "description": "Invalid token or no param provided", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/profile": { "get": { "description": "Get profile of the current session", @@ -186,7 +300,7 @@ "summary": "Patch profile", "parameters": [ { - "description": "Profile data (picture is ignored from request - use UploadPicture endpoint)", + "description": "Profile patch data", "name": "profile", "in": "body", "required": true, @@ -467,6 +581,20 @@ } } }, + "dto.PasswordRecoveryDTO": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, "dto.ProfilePatchDTO": { "type": "object", "properties": { @@ -545,10 +673,15 @@ "dto.UserResponseDTO": { "type": "object", "required": [ + "active", "email", + "id", "name" ], "properties": { + "active": { + "type": "boolean" + }, "email": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1969e2c..4b99325 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -34,6 +34,15 @@ definitions: - email - password type: object + dto.PasswordRecoveryDTO: + properties: + email: + type: string + password: + type: string + required: + - email + type: object dto.ProfilePatchDTO: properties: bio: @@ -86,6 +95,8 @@ definitions: type: object dto.UserResponseDTO: properties: + active: + type: boolean email: type: string id: @@ -93,7 +104,9 @@ definitions: name: type: string required: + - active - email + - id - name type: object info: @@ -125,6 +138,46 @@ paths: summary: Verify Session tags: - users + /auth/activate/{id}: + get: + description: Activate a user account with the activation token or refresh the + token + parameters: + - description: User ID + in: path + name: id + required: true + type: string + - description: Activation token + in: query + name: token + type: string + - description: Refresh activation token (true/false) + in: query + name: refresh + type: string + produces: + - application/json + responses: + "200": + description: User data (Wrapped in data envelope) + schema: + $ref: '#/definitions/dto.UserResponseDTO' + "400": + description: Bad request body + schema: + $ref: '#/definitions/dto.ErrorResponse' + "401": + description: Invalid token or no param provided or account already active + schema: + $ref: '#/definitions/dto.ErrorResponse' + "403": + description: Already logged in + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: Account Activation + tags: + - users /auth/google: post: consumes: @@ -190,6 +243,43 @@ paths: summary: Login tags: - users + /auth/recovery: + post: + consumes: + - application/json + description: Request a password recovery code or change the password with the + code + parameters: + - description: Password recovery token + in: query + name: token + type: string + - description: Refresh password recovery code (true/false) + in: query + name: refresh + type: string + - description: Password recovery data + in: body + name: request + required: true + schema: + $ref: '#/definitions/dto.PasswordRecoveryDTO' + produces: + - application/json + responses: + "204": + description: Password recovery code sent or changed successfully + "400": + description: Bad request body + schema: + $ref: '#/definitions/dto.ErrorResponse' + "401": + description: Invalid token or no param provided + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: Password Recovery + tags: + - users /profile: get: description: Get profile of the current session @@ -220,8 +310,7 @@ paths: - application/json description: Update profile by user ID parameters: - - description: Profile data (picture is ignored from request - use UploadPicture - endpoint) + - description: Profile patch data in: body name: profile required: true diff --git a/internal/dto/user_auth.go b/internal/dto/user_auth.go index b04d3ba..bcde60e 100644 --- a/internal/dto/user_auth.go +++ b/internal/dto/user_auth.go @@ -15,6 +15,11 @@ type LoginDTO struct { Password string `json:"password" binding:"required,min=8"` } +type PasswordRecoveryDTO struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password"` +} + // GoogleAuthDTO represents the user data transfer object // that is used for Google login. type GoogleAuthDTO struct { diff --git a/internal/handlers/users/auth/handler.go b/internal/handlers/users/auth/handler.go index 3f8f3b2..da619de 100644 --- a/internal/handlers/users/auth/handler.go +++ b/internal/handlers/users/auth/handler.go @@ -18,4 +18,7 @@ type AuthHandler interface { // AccountActivation handles the account activation process based on the provided token. AccountActivation(c *gin.Context) + + // RecoverPassword handles the password change process for a user or token refresh. + RecoverPassword(c *gin.Context) } diff --git a/internal/handlers/users/auth/login.go b/internal/handlers/users/auth/login.go index 48547d4..70f5cf4 100644 --- a/internal/handlers/users/auth/login.go +++ b/internal/handlers/users/auth/login.go @@ -213,6 +213,53 @@ func (h *authHandler) AccountActivation(c *gin.Context) { }) } +// @Summary Password Recovery +// @Description Request a password recovery code or change the password with the code +// @Tags users +// @Accept json +// @Produce json +// @Param token query string false "Password recovery token" +// @Param refresh query string false "Refresh password recovery code (true/false)" +// @Param request body dto.PasswordRecoveryDTO true "Password recovery data" +// @Success 204 "Password recovery code sent or changed successfully" +// @Failure 400 {object} dto.ErrorResponse "Bad request body" +// @Failure 401 {object} dto.ErrorResponse "Invalid token or no param provided" +// @Router /auth/recovery [post] +func (h *authHandler) RecoverPassword(c *gin.Context) { + token := c.Query("token") + refresh := c.Query("refresh") + if token == "" && refresh == "" { + _ = c.Error(errors.NewUnauthorizedError("missing activation token or refresh param")) + return + } + + var recoveryInfo dto.PasswordRecoveryDTO + if err := c.ShouldBindJSON(&recoveryInfo); err != nil { + _ = c.Error(errors.NewBadRequestError(err.Error())) + return + } + + if token != "" { + if recoveryInfo.Password != "" && len(recoveryInfo.Password) < 8 { + _ = c.Error(errors.NewBadRequestError("password must be at least 8 characters long")) + return + } + err := h.service.ChangePassword(recoveryInfo.Email, recoveryInfo.Password, token) + if err != nil { + _ = c.Error(err) + return + } + } else if refresh == "true" { + err := h.service.RefreshPasswordRecoveryCode(recoveryInfo.Email) + if err != nil { + _ = c.Error(err) + return + } + } + + handlers.HandleBodilessResponse(c, http.StatusNoContent) +} + /* Helper Functions */ // handleValidSession checks if the JWT token is valid and if the user session is valid. diff --git a/internal/handlers/users/auth/login_test.go b/internal/handlers/users/auth/login_test.go index e2b55b1..c7a209e 100644 --- a/internal/handlers/users/auth/login_test.go +++ b/internal/handlers/users/auth/login_test.go @@ -40,6 +40,12 @@ func TestMain(m *testing.M) { Email: "test@email.com", Password: string(hashedPassword), }) + _ = repo.CreateUser(&models.User{ + Name: "Test User 2", + Email: "test2@email.com", + Password: string(hashedPassword), + Active: true, // This user is already activated + }) conf := &config.Config{ GOOGLE_CLIENT_ID: "test-client-id", @@ -55,6 +61,7 @@ func TestMain(m *testing.M) { router.POST("/auth/login", authHandler.Login) router.POST("/auth/google", authHandler.GoogleLogin) router.GET("/auth/activation/:id", authHandler.AccountActivation) + router.POST("/auth/recovery", authHandler.RecoverPassword) router.GET("/auth/", authHandler.VerifySession) // Run the tests @@ -292,27 +299,48 @@ func TestAccountActivation_RefreshToken(t *testing.T) { assert.NotContains(t, w.Header().Get("Authorization"), "Bearer") } -func TestAccountActivation_IncludeToken(t *testing.T) { - user, _ := repo.GetUserByEmail("test@email.com") - assert.False(t, user.Active, "user should not be activated") +func TestAccountActivation(t *testing.T) { + user, _ := repo.GetUserByEmail("test2@email.com") - // invalid token - req, _ := http.NewRequest("GET", "/auth/activation/"+user.ID.String()+"?token=invalid-token", nil) + pwRecoveryDTO := &dto.PasswordRecoveryDTO{ + Email: user.Email, + Password: "", + } + body, _ := json.Marshal(pwRecoveryDTO) + + // no token or refresh + req, _ := http.NewRequest("POST", "/auth/recovery", bytes.NewBuffer(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code, "should return unauthorized for missing token or refresh") + + // refresh token + req, _ = http.NewRequest("POST", "/auth/recovery?refresh=true", bytes.NewBuffer(body)) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusNoContent, w.Code, "should return 204 if refresh is successful") + + // invalid token + req, _ = http.NewRequest("POST", "/auth/recovery?token=invalid-token", bytes.NewBuffer(body)) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code, "should return unauthorized for invalid token") - assert.NotContains(t, w.Header().Get("Authorization"), "Bearer") - // valid token - req, _ = http.NewRequest("GET", "/auth/activation/"+user.ID.String()+"?token=valid-token", nil) + // short password + pwRecoveryDTO.Password = "short" + body, _ = json.Marshal(pwRecoveryDTO) + req, _ = http.NewRequest("POST", "/auth/recovery?token=valid-token", bytes.NewBuffer(body)) w = httptest.NewRecorder() router.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code, "should return OK for valid token") - assert.Contains(t, w.Header().Get("Authorization"), "Bearer") + assert.Equal(t, http.StatusBadRequest, w.Code, "should return bad request for short password") - // Check if the user is activated - user, _ = repo.GetUserByID(user.ID) - assert.True(t, user.Active, "user should be activated") + // valid token and password + pwRecoveryDTO.Password = "new-password123" + body, _ = json.Marshal(pwRecoveryDTO) + req, _ = http.NewRequest("POST", "/auth/recovery?token=valid-token", bytes.NewBuffer(body)) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusNoContent, w.Code, "should return 204 for valid token") } /* HELPER FUNCTIONS */ diff --git a/internal/repository/memory/users.go b/internal/repository/memory/users.go index e13a6db..714fd5c 100644 --- a/internal/repository/memory/users.go +++ b/internal/repository/memory/users.go @@ -29,7 +29,6 @@ func (r *inMemoryUsersRepository) CreateUser(user *models.User) error { id := uuid.New() user.ID = id - user.Active = false // Set user as inactive by default r.usersDB[id] = *user return nil @@ -103,3 +102,24 @@ func (r *inMemoryUsersRepository) RefreshActivationToken(id uuid.UUID) error { // Non mock impl generates a new token and sends it to the user. return nil } + +func (r *inMemoryUsersRepository) RefreshPasswordRecoveryCode(id uuid.UUID) error { + // Non mock impl generates a recovery code and sends it to the user. + return nil +} + +func (r *inMemoryUsersRepository) ChangePassword(id uuid.UUID, newPassword string, token string) error { + user, exists := r.usersDB[id] + if !exists { + return errors.NewNotFoundError("user not found") + } + + if token != "valid-token" { + return errors.NewUnauthorizedError("invalid token") + } + + user.Password = newPassword // In a real implementation, you would hash the password + r.usersDB[id] = user + + return nil +} diff --git a/internal/repository/users/postgres.go b/internal/repository/users/postgres.go index e94bba2..147b382 100644 --- a/internal/repository/users/postgres.go +++ b/internal/repository/users/postgres.go @@ -179,3 +179,48 @@ func (r *usersRepository) RefreshActivationToken(id uuid.UUID) error { return nil } + +func (r *usersRepository) RefreshPasswordRecoveryCode(id uuid.UUID) error { + var accountActivation models.AccountActivation + if err := r.db.Where("user_id = ?", id).First(&accountActivation).Error; err != nil { + if !stdErrors.Is(err, gorm.ErrRecordNotFound) { + return errors.NewInternalServerError("could not get account activation: " + err.Error()) + } + } + + newToken := utils.GenerateActivationToken() + accountActivation.UserID = id + accountActivation.Token = newToken + accountActivation.ExpiresAt = time.Now().Add(10 * time.Minute) + if err := r.db.Save(&accountActivation).Error; err != nil { + return errors.NewInternalServerError("could not create password recovery code: " + err.Error()) + } + + user, _ := r.GetUserByID(id) + utils.SendPasswordResetTokenToUser(r.conf.NOTIFICATIONS_SERVICE, user.Email, newToken) + return nil +} + +func (r *usersRepository) ChangePassword(id uuid.UUID, newPassword string, token string) error { + var accountActivation models.AccountActivation + if err := r.db.Where("user_id = ?", id).First(&accountActivation).Error; err != nil { + if stdErrors.Is(err, gorm.ErrRecordNotFound) { + return errors.NewUnauthorizedError("no password recovery code found") + } + return errors.NewInternalServerError("could not get account activation: " + err.Error()) + } + + if accountActivation.Token != token || accountActivation.ExpiresAt.Before(time.Now()) { + return errors.NewUnauthorizedError("invalid or expired token") + } + + if err := r.db.Model(&models.User{}).Where("id = ?", id).Update("password", newPassword).Error; err != nil { + return errors.NewInternalServerError("could not change password: " + err.Error()) + } + + if err := r.db.Delete(&accountActivation).Error; err != nil { + return errors.NewInternalServerError("could not delete account activation: " + err.Error()) + } + + return nil +} diff --git a/internal/repository/users/postgres_test.go b/internal/repository/users/postgres_test.go index bc7ae25..8ee2320 100644 --- a/internal/repository/users/postgres_test.go +++ b/internal/repository/users/postgres_test.go @@ -413,6 +413,106 @@ func TestRefreshToken_Success(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } +func TestRefreshPasswordRecoveryToken(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectQuery(`SELECT \* FROM "account_activations" WHERE user_id = \$1 ORDER BY "account_activations"\."user_id" LIMIT \$\d+`). + WithArgs(userID, 1). + WillReturnError(gorm.ErrRecordNotFound) + + // saves the new token + mock.ExpectBegin() + mock.ExpectExec(`UPDATE "account_activations"`). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), userID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + mock.ExpectQuery(`SELECT \* FROM "users" WHERE "users"\."id" = \$1 ORDER BY "users"\."id" LIMIT \$\d+`). + WithArgs(userID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "name", "email", "password"}). + AddRow(userID, "name", "email", "password")) + + err := repo.RefreshPasswordRecoveryCode(userID) + assert.NoError(t, err) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestChangePassword_TokenNotFound(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectQuery(`SELECT \* FROM "account_activations" WHERE user_id = \$1 ORDER BY "account_activations"\."user_id" LIMIT \$\d+`). + WithArgs(userID, 1). + WillReturnError(gorm.ErrRecordNotFound) + + err := repo.ChangePassword(userID, "new-password123", "invalid-token") + assert.Error(t, err) + _, ok := err.(*errors.UnauthorizedError) + assert.True(t, ok) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestChangePassword_TokenInvalid(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectQuery(`SELECT \* FROM "account_activations" WHERE user_id = \$1 ORDER BY "account_activations"\."user_id" LIMIT \$\d+`). + WithArgs(userID, 1). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "token", "expires_at"}). + AddRow(userID, "old-token", time.Now().Add(time.Hour))) + + err := repo.ChangePassword(userID, "new-password123", "invalid-token") + assert.Error(t, err) + _, ok := err.(*errors.UnauthorizedError) + assert.True(t, ok) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestChangePassword_Success(t *testing.T) { + db, mock, cleanup := setupMockDB(t) + defer cleanup() + + repo := NewUsersRepository(db, conf) + userID := uuid.New() + + mock.ExpectQuery(`SELECT \* FROM "account_activations" WHERE user_id = \$1 ORDER BY "account_activations"\."user_id" LIMIT \$\d+`). + WithArgs(userID, 1). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "token", "expires_at"}). + AddRow(userID, "old-token", time.Now().Add(time.Hour))) + + // user update + mock.ExpectBegin() + mock.ExpectExec(`UPDATE "users"`). + WithArgs("new-password123", userID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + // account activation delete + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM "account_activations"`). + WithArgs(userID). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + err := repo.ChangePassword(userID, "new-password123", "old-token") + assert.NoError(t, err) + + assert.NoError(t, mock.ExpectationsWereMet()) +} + /* HELPER FUNCTIONS */ func setupMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { diff --git a/internal/repository/users/repository.go b/internal/repository/users/repository.go index 4164a78..3aad468 100644 --- a/internal/repository/users/repository.go +++ b/internal/repository/users/repository.go @@ -30,4 +30,10 @@ type UsersRepository interface { // RefreshActivationToken refreshes the activation token for a user and re-sends it. RefreshActivationToken(id uuid.UUID) error + + // RefreshPasswordRecoveryCode generates a password recovery code for a user and sends it via email. + RefreshPasswordRecoveryCode(id uuid.UUID) error + + // ChangePassword changes the password for a user. + ChangePassword(id uuid.UUID, newPassword string, token string) error } diff --git a/internal/router/auth_router.go b/internal/router/auth_router.go index 1af7b39..cca0af3 100644 --- a/internal/router/auth_router.go +++ b/internal/router/auth_router.go @@ -25,4 +25,5 @@ func InitAuthRoutes(r *gin.Engine, db *gorm.DB, conf *config.Config) { group.GET("", authHandler.VerifySession) group.GET("activation/:id", authHandler.AccountActivation) + group.POST("recovery", authHandler.RecoverPassword) } diff --git a/internal/services/users/auth/login.go b/internal/services/users/auth/login.go index 65eebff..c48d35a 100644 --- a/internal/services/users/auth/login.go +++ b/internal/services/users/auth/login.go @@ -102,6 +102,38 @@ func (s *authService) RefreshActivationToken(id uuid.UUID) (*models.User, error) return s.repo.GetUserByID(id) } +func (s *authService) RefreshPasswordRecoveryCode(email string) error { + user, err := s.repo.GetUserByEmail(email) + if err != nil { + return err + } else if !user.Active { + return errors.NewUnauthorizedError("user account is not active") + } + if err := s.repo.RefreshPasswordRecoveryCode(user.ID); err != nil { + return err + } + return nil +} + +func (s *authService) ChangePassword(email string, newPassword, token string) error { + user, err := s.repo.GetUserByEmail(email) + if err != nil { + return errors.NewUnauthorizedError("email is not linked to any account") + } else if !user.Active { + return errors.NewUnauthorizedError("user account is not active") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return errors.NewInternalServerError("could not hash password: " + err.Error()) + } + if err := s.repo.ChangePassword(user.ID, string(hashedPassword), token); err != nil { + return err + } + + return nil +} + /* HELPER FUNCTIONS */ // googleSignup creates a user using the parsed token payload data. diff --git a/internal/services/users/auth/login_test.go b/internal/services/users/auth/login_test.go index eceaac5..a7a2767 100644 --- a/internal/services/users/auth/login_test.go +++ b/internal/services/users/auth/login_test.go @@ -35,6 +35,19 @@ func TestMain(m *testing.M) { } _ = repository.CreateUser(user) user_id = user.ID + user = &models.User{ + Name: "Test User 2", + Email: "test2@email.com", + Password: string(hashedPassword), + Active: true, + } + _ = repository.CreateUser(user) + user = &models.User{ + Name: "Test User perma-inactive", + Email: "test3@email.com", + Password: string(hashedPassword), + } + _ = repository.CreateUser(user) utils.VerifyGoogleToken = mockVerifyGoogleLogin @@ -187,6 +200,44 @@ func TestActivateAccount_Activate(t *testing.T) { assert.True(t, user.Active) } +func TestRefreshPasswordRecoveryCode(t *testing.T) { + // inexisting user + err := authService.RefreshPasswordRecoveryCode("inexisting@email.com") + assert.Error(t, err) + assert.IsType(t, err, &errors.NotFoundError{}) + + // inactive user + err = authService.RefreshPasswordRecoveryCode("test3@email.com") + assert.Error(t, err) + assert.IsType(t, err, &errors.UnauthorizedError{}) + + // active user + err = authService.RefreshPasswordRecoveryCode("test2@email.com") + assert.NoError(t, err) +} + +func TestPasswordRecovery(t *testing.T) { + newPassword := "new-password123" + // inexisting user + err := authService.ChangePassword("inexisting@email.com", newPassword, "valid-token") + assert.Error(t, err) + assert.IsType(t, err, &errors.UnauthorizedError{}) + + // inactive user + err = authService.ChangePassword("test3@email.com", newPassword, "valid-token") + assert.Error(t, err) + assert.IsType(t, err, &errors.UnauthorizedError{}) + + // active user with invalid token + err = authService.ChangePassword("test2@email.com", newPassword, "invalid-token") + assert.Error(t, err) + assert.IsType(t, err, &errors.UnauthorizedError{}) + + // active user with valid token + err = authService.ChangePassword("test2@email.com", newPassword, "valid-token") + assert.NoError(t, err) +} + /* HELPER FUNCTIONS */ // mockVerifyGoogleLogin is a mock function to simulate Google token verification. diff --git a/internal/services/users/auth/service.go b/internal/services/users/auth/service.go index 891d777..c1a02b5 100644 --- a/internal/services/users/auth/service.go +++ b/internal/services/users/auth/service.go @@ -23,4 +23,10 @@ type AuthService interface { // RefreshActivationToken refreshes the activation token for a user account. RefreshActivationToken(id uuid.UUID) (*models.User, error) + + // RefreshPasswordRecoveryCode generates a new password recovery code for a user. + RefreshPasswordRecoveryCode(email string) error + + // ChangePassword changes the user's password using the provided email, new password, and token. + ChangePassword(email string, newPassword, token string) error } diff --git a/internal/utils/send_email.go b/internal/utils/send_email.go index 4d237de..27854e1 100644 --- a/internal/utils/send_email.go +++ b/internal/utils/send_email.go @@ -38,6 +38,16 @@ func SendAccountSuccesfullyActivated(url, email, name string) { SendEmailRequest(url, req) } +func SendPasswordResetTokenToUser(url, email, token string) { + req := EmailRequest{ + Email: []string{email}, + Subject: "Change your password", + Body: fmt.Sprintf("Your password recovery code for the class-connect mobile app is: %s", token), + } + + SendEmailRequest(url, req) +} + func sendEmailRequestImpl(url string, req EmailRequest) { reqBody, err := json.Marshal(req) if err != nil { diff --git a/internal/utils/send_email_test.go b/internal/utils/send_email_test.go index e31a1ef..a47e9e0 100644 --- a/internal/utils/send_email_test.go +++ b/internal/utils/send_email_test.go @@ -34,3 +34,17 @@ func TestSendAccountSuccesfullyActivated(t *testing.T) { SendAccountSuccesfullyActivated(url, email, name) } + +func TestSendPasswordResetTokenToUser(t *testing.T) { + url := "some-url" + email := "test@email.com" + token := "test-token" + SendEmailRequest = func(url string, req EmailRequest) { + assert.Equal(t, url, "some-url") + assert.Equal(t, req.Email, []string{"test@email.com"}) + assert.Contains(t, req.Subject, "Change your password") + assert.Contains(t, req.Body, "test-token") + } + + SendPasswordResetTokenToUser(url, email, token) +}