feat(auth): align password reset flows
This commit is contained in:
@@ -83,6 +83,16 @@ type ResetPasswordRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
type ConfirmResetPasswordRequest struct {
|
||||
ResetPasswordToken string `json:"reset_password_token" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
PasswordConfirmation string `json:"password_confirmation" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
type ConfirmEmailRequest struct {
|
||||
ConfirmationToken string `json:"confirmation_token" binding:"required"`
|
||||
}
|
||||
|
||||
// OAuthCallbackRequest is the JSON body for OAuth callback.
|
||||
type OAuthCallbackRequest struct {
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
@@ -358,9 +368,35 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
Email: req.Email,
|
||||
})
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"message": "If the email exists, a reset link has been sent.",
|
||||
c.JSON(http.StatusOK, gin.H{"message": service.ChatwootPasswordResetMessage})
|
||||
}
|
||||
|
||||
// ConfirmResetPassword completes Chatwoot's Devise-compatible password reset.
|
||||
// PUT /auth/password
|
||||
func (h *AuthHandler) ConfirmResetPassword(c *gin.Context) {
|
||||
var req ConfirmResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
output, err := h.authService.ConfirmResetPassword(c.Request.Context(), &service.ConfirmResetPasswordInput{
|
||||
Token: req.ResetPasswordToken,
|
||||
Password: req.Password,
|
||||
PasswordConfirmation: req.PasswordConfirmation,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error(), "redirect_url": "/"})
|
||||
return
|
||||
}
|
||||
|
||||
h.setChatwootAuthHeaders(c, output)
|
||||
data, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID)
|
||||
if err != nil {
|
||||
handleServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
// ConfirmEmail verifies email confirmation token.
|
||||
@@ -372,7 +408,7 @@ func (h *AuthHandler) ConfirmEmail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.ConfirmEmail(c.Request.Context(), &service.ConfirmEmailInput{
|
||||
output, err := h.authService.ConfirmEmail(c.Request.Context(), &service.ConfirmEmailInput{
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -381,11 +417,35 @@ func (h *AuthHandler) ConfirmEmail(c *gin.Context) {
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"user": user,
|
||||
"user": output.User,
|
||||
"message": "Email confirmed successfully.",
|
||||
})
|
||||
}
|
||||
|
||||
// ChatwootConfirmEmail verifies the confirmation token from the reused frontend.
|
||||
// POST /auth/confirmation
|
||||
func (h *AuthHandler) ChatwootConfirmEmail(c *gin.Context) {
|
||||
var req ConfirmEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
output, err := h.authService.ConfirmEmail(c.Request.Context(), &service.ConfirmEmailInput{Token: req.ConfirmationToken})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"message": err.Error(), "redirect_url": "/"})
|
||||
return
|
||||
}
|
||||
|
||||
h.setChatwootAuthHeaders(c, output)
|
||||
data, err := h.chatwootUserPayload(c, output.User.ID, output.AccountID)
|
||||
if err != nil {
|
||||
handleServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
// OAuthCallback handles OAuth2 provider callback.
|
||||
// POST /api/v1/auth/oauth/callback
|
||||
// Receives provider + code from frontend (frontend handles redirect flow).
|
||||
@@ -470,6 +530,7 @@ func RegisterAuthRoutes(rg *gin.RouterGroup, handler *AuthHandler) {
|
||||
|
||||
// Password & email
|
||||
authGroup.POST("/reset_password", handler.ResetPassword)
|
||||
authGroup.PUT("/reset_password", handler.ConfirmResetPassword)
|
||||
authGroup.GET("/confirm_email", handler.ConfirmEmail)
|
||||
|
||||
// OAuth
|
||||
@@ -484,6 +545,8 @@ func RegisterChatwootAuthRoutes(rg *gin.RouterGroup, handler *AuthHandler) {
|
||||
rg.DELETE("/sign_out", handler.ChatwootSignOut)
|
||||
rg.GET("/validate_token", handler.ChatwootValidateToken)
|
||||
rg.POST("/password", handler.ResetPassword)
|
||||
rg.PUT("/password", handler.ConfirmResetPassword)
|
||||
rg.POST("/confirmation", handler.ChatwootConfirmEmail)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) chatwootUserPayload(c *gin.Context, userID uint, accountID uint) (any, error) {
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"github.com/gochat/gochat/pkg/crypto"
|
||||
)
|
||||
|
||||
func setupChatwootAuthTest(t *testing.T) (*gin.Engine, *model.User) {
|
||||
func setupChatwootAuthTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.User) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -60,11 +60,11 @@ func setupChatwootAuthTest(t *testing.T) (*gin.Engine, *model.User) {
|
||||
|
||||
router := gin.New()
|
||||
RegisterChatwootAuthRoutes(router.Group("/auth"), handler)
|
||||
return router, user
|
||||
return router, db, user
|
||||
}
|
||||
|
||||
func TestChatwootAuthSignInReturnsDeviseHeadersAndUserPayload(t *testing.T) {
|
||||
router, _ := setupChatwootAuthTest(t)
|
||||
router, _, _ := setupChatwootAuthTest(t)
|
||||
body, _ := json.Marshal(map[string]string{"email": " AUTH@example.com ", "password": "password123"})
|
||||
req, _ := http.NewRequest(http.MethodPost, "/auth/sign_in", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -93,7 +93,7 @@ func TestChatwootAuthSignInReturnsDeviseHeadersAndUserPayload(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChatwootAuthValidateTokenReturnsPayloadData(t *testing.T) {
|
||||
router, _ := setupChatwootAuthTest(t)
|
||||
router, _, _ := setupChatwootAuthTest(t)
|
||||
token := signInAndReturnAccessToken(t, router)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/auth/validate_token", nil)
|
||||
req.Header.Set("access-token", token)
|
||||
@@ -111,7 +111,7 @@ func TestChatwootAuthValidateTokenReturnsPayloadData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChatwootAuthSignOutRevokesRefreshSession(t *testing.T) {
|
||||
router, _ := setupChatwootAuthTest(t)
|
||||
router, _, _ := setupChatwootAuthTest(t)
|
||||
token := signInAndReturnAccessToken(t, router)
|
||||
req, _ := http.NewRequest(http.MethodDelete, "/auth/sign_out", nil)
|
||||
req.Header.Set("access-token", token)
|
||||
@@ -125,6 +125,74 @@ func TestChatwootAuthSignOutRevokesRefreshSession(t *testing.T) {
|
||||
require.Equal(t, true, body["success"])
|
||||
}
|
||||
|
||||
func TestChatwootAuthResetPasswordReturnsReferenceMessage(t *testing.T) {
|
||||
router, db, user := setupChatwootAuthTest(t)
|
||||
body, _ := json.Marshal(map[string]string{"email": "auth@example.com"})
|
||||
req, _ := http.NewRequest(http.MethodPost, "/auth/password", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
require.Equal(t, service.ChatwootPasswordResetMessage, resp["message"])
|
||||
var updated model.User
|
||||
require.NoError(t, db.First(&updated, user.ID).Error)
|
||||
require.NotEmpty(t, updated.ResetPasswordToken)
|
||||
}
|
||||
|
||||
func TestChatwootAuthConfirmResetPasswordReturnsAuthPayload(t *testing.T) {
|
||||
router, db, user := setupChatwootAuthTest(t)
|
||||
require.NoError(t, db.Model(user).Updates(map[string]interface{}{
|
||||
"reset_password_token": "raw-reset-token",
|
||||
"reset_password_sent_at": time.Now().UTC(),
|
||||
}).Error)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"reset_password_token": "raw-reset-token",
|
||||
"password": "newpassword",
|
||||
"password_confirmation": "newpassword",
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPut, "/auth/password", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
require.NotEmpty(t, w.Header().Get("access-token"))
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
data := resp["data"].(map[string]any)
|
||||
require.Equal(t, "auth@example.com", data["email"])
|
||||
var updated model.User
|
||||
require.NoError(t, db.First(&updated, user.ID).Error)
|
||||
require.True(t, crypto.CheckPassword("newpassword", updated.PasswordDigest))
|
||||
require.Empty(t, updated.ResetPasswordToken)
|
||||
}
|
||||
|
||||
func TestChatwootAuthConfirmationReturnsAuthPayload(t *testing.T) {
|
||||
router, db, user := setupChatwootAuthTest(t)
|
||||
require.NoError(t, db.Model(user).Updates(map[string]interface{}{
|
||||
"confirmation_token": "raw-confirm-token",
|
||||
"confirmed_at": nil,
|
||||
}).Error)
|
||||
body, _ := json.Marshal(map[string]string{"confirmation_token": "raw-confirm-token"})
|
||||
req, _ := http.NewRequest(http.MethodPost, "/auth/confirmation", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
require.NotEmpty(t, w.Header().Get("access-token"))
|
||||
var updated model.User
|
||||
require.NoError(t, db.First(&updated, user.ID).Error)
|
||||
require.NotNil(t, updated.ConfirmedAt)
|
||||
require.Empty(t, updated.ConfirmationToken)
|
||||
}
|
||||
|
||||
func signInAndReturnAccessToken(t *testing.T, router *gin.Engine) string {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{"email": "auth@example.com", "password": "password123"})
|
||||
|
||||
Reference in New Issue
Block a user