feat(auth): align password reset flows
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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"})
|
||||
|
||||
+28
-24
@@ -12,30 +12,34 @@ import (
|
||||
// User represents an agent/admin user in the system.
|
||||
type User struct {
|
||||
Base
|
||||
AccountID uint `gorm:"index;not null" json:"account_id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
|
||||
Password string `gorm:"size:255;not null" json:"-"` // hashed password (bcrypt)
|
||||
PasswordDigest string `gorm:"size:255" json:"-"` // alias used by auth service
|
||||
Provider string `gorm:"size:50;default:email" json:"provider"` // email, google, saml
|
||||
UID string `gorm:"size:255" json:"uid,omitempty"` // external ID for OAuth providers
|
||||
AvatarURL string `gorm:"size:512" json:"avatar_url"`
|
||||
DisplayName string `gorm:"size:255" json:"display_name"`
|
||||
MessageSignature string `gorm:"type:text" json:"message_signature"`
|
||||
PubsubToken string `gorm:"size:255;uniqueIndex" json:"pubsub_token"`
|
||||
UISettings datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"ui_settings"`
|
||||
CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"`
|
||||
Role string `gorm:"size:50;default:agent" json:"role"` // agent, admin
|
||||
Type string `gorm:"size:50;default:user" json:"type"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
Available bool `gorm:"default:false" json:"available"`
|
||||
TOTPSecret string `gorm:"size:255" json:"totp_secret,omitempty"`
|
||||
TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"`
|
||||
CustomRoleID *uint `gorm:"index" json:"custom_role_id,omitempty"`
|
||||
SignInCount int `gorm:"default:0" json:"sign_in_count"`
|
||||
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
|
||||
LastSignInAt *time.Time `json:"last_sign_in_at,omitempty"`
|
||||
CurrentSignInAt *time.Time `json:"current_sign_in_at,omitempty"`
|
||||
AccountID uint `gorm:"index;not null" json:"account_id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
|
||||
Password string `gorm:"size:255;not null" json:"-"` // hashed password (bcrypt)
|
||||
PasswordDigest string `gorm:"size:255" json:"-"` // alias used by auth service
|
||||
Provider string `gorm:"size:50;default:email" json:"provider"` // email, google, saml
|
||||
UID string `gorm:"size:255" json:"uid,omitempty"` // external ID for OAuth providers
|
||||
AvatarURL string `gorm:"size:512" json:"avatar_url"`
|
||||
DisplayName string `gorm:"size:255" json:"display_name"`
|
||||
MessageSignature string `gorm:"type:text" json:"message_signature"`
|
||||
PubsubToken string `gorm:"size:255;uniqueIndex" json:"pubsub_token"`
|
||||
UISettings datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"ui_settings"`
|
||||
CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"`
|
||||
Role string `gorm:"size:50;default:agent" json:"role"` // agent, admin
|
||||
Type string `gorm:"size:50;default:user" json:"type"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
Available bool `gorm:"default:false" json:"available"`
|
||||
TOTPSecret string `gorm:"size:255" json:"totp_secret,omitempty"`
|
||||
TOTPEnabled bool `gorm:"default:false" json:"totp_enabled"`
|
||||
CustomRoleID *uint `gorm:"index" json:"custom_role_id,omitempty"`
|
||||
SignInCount int `gorm:"default:0" json:"sign_in_count"`
|
||||
ResetPasswordToken string `gorm:"size:255;index" json:"-"`
|
||||
ResetPasswordSentAt *time.Time `json:"-"`
|
||||
ConfirmationToken string `gorm:"size:255;index" json:"-"`
|
||||
ConfirmationSentAt *time.Time `json:"-"`
|
||||
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
|
||||
LastSignInAt *time.Time `json:"last_sign_in_at,omitempty"`
|
||||
CurrentSignInAt *time.Time `json:"current_sign_in_at,omitempty"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
|
||||
@@ -2,6 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,9 +13,11 @@ import (
|
||||
|
||||
"github.com/gochat/gochat/internal/auth"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/pkg/crypto"
|
||||
pkgcrypto "github.com/gochat/gochat/pkg/crypto"
|
||||
)
|
||||
|
||||
const ChatwootPasswordResetMessage = "Request for password reset is successful. A email with instructions will be sent to your email if it exists."
|
||||
|
||||
// Reference: P2E §1 — Auth business logic service
|
||||
// Encapsulates all authentication business logic so handlers remain thin wrappers.
|
||||
// Pattern follows Chatwoot's service_object pattern (app/services/).
|
||||
@@ -85,7 +90,7 @@ func (s *AuthService) Login(ctx context.Context, input *LoginInput) (*LoginOutpu
|
||||
return nil, fmt.Errorf("this account uses %s authentication, please login via that provider", user.Provider)
|
||||
}
|
||||
|
||||
if !crypto.CheckPassword(input.Password, user.PasswordDigest) && !crypto.CheckPassword(input.Password, user.Password) {
|
||||
if !pkgcrypto.CheckPassword(input.Password, user.PasswordDigest) && !pkgcrypto.CheckPassword(input.Password, user.Password) {
|
||||
return nil, fmt.Errorf("invalid email or password")
|
||||
}
|
||||
|
||||
@@ -225,7 +230,7 @@ func (s *AuthService) Register(ctx context.Context, input *RegisterInput) (*mode
|
||||
}
|
||||
|
||||
// Hash password
|
||||
passwordDigest, err := crypto.HashPassword(input.Password)
|
||||
passwordDigest, err := pkgcrypto.HashPassword(input.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
@@ -382,26 +387,75 @@ type ResetPasswordInput struct {
|
||||
// Generates a reset token stored in Redis, sends email with reset link.
|
||||
func (s *AuthService) ResetPassword(ctx context.Context, input *ResetPasswordInput) error {
|
||||
var user model.User
|
||||
if err := s.db.Where("email = ?", input.Email).First(&user).Error; err != nil {
|
||||
email := strings.TrimSpace(strings.ToLower(input.Email))
|
||||
if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
|
||||
// Don't reveal whether email exists — security best practice
|
||||
return nil
|
||||
}
|
||||
|
||||
// Production note: reset token should be generated, stored in Redis with TTL, and emailed via worker.
|
||||
// Placeholder for development
|
||||
return nil
|
||||
token, err := generateAuthToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate reset token: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return s.db.WithContext(ctx).Model(&user).Updates(map[string]interface{}{
|
||||
"reset_password_token": digestAuthToken(token),
|
||||
"reset_password_sent_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ConfirmResetPasswordInput holds password reset confirmation parameters.
|
||||
type ConfirmResetPasswordInput struct {
|
||||
Token string
|
||||
Password string
|
||||
Token string
|
||||
Password string
|
||||
PasswordConfirmation string
|
||||
}
|
||||
|
||||
// ConfirmResetPassword completes password reset by verifying token and updating password.
|
||||
func (s *AuthService) ConfirmResetPassword(ctx context.Context, input *ConfirmResetPasswordInput) error {
|
||||
// Production note: reset token must be verified from Redis before password update.
|
||||
return fmt.Errorf("password reset confirmation not yet implemented")
|
||||
func (s *AuthService) ConfirmResetPassword(ctx context.Context, input *ConfirmResetPasswordInput) (*LoginOutput, error) {
|
||||
token := strings.TrimSpace(input.Token)
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("Invalid token")
|
||||
}
|
||||
if input.Password == "" || input.Password != input.PasswordConfirmation {
|
||||
return nil, fmt.Errorf("invalid password confirmation")
|
||||
}
|
||||
|
||||
var user model.User
|
||||
digest := digestAuthToken(token)
|
||||
if err := s.db.WithContext(ctx).Where("reset_password_token IN ?", []string{digest, token}).First(&user).Error; err != nil {
|
||||
return nil, fmt.Errorf("Invalid token")
|
||||
}
|
||||
if user.ResetPasswordSentAt != nil && time.Since(*user.ResetPasswordSentAt) > 6*time.Hour {
|
||||
return nil, fmt.Errorf("Invalid token")
|
||||
}
|
||||
|
||||
passwordDigest, err := pkgcrypto.HashPassword(input.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]interface{}{
|
||||
"password": passwordDigest,
|
||||
"password_digest": passwordDigest,
|
||||
"reset_password_token": "",
|
||||
"reset_password_sent_at": nil,
|
||||
"confirmation_token": "",
|
||||
}
|
||||
if user.ConfirmedAt == nil {
|
||||
updates["confirmed_at"] = now
|
||||
user.ConfirmedAt = &now
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&user).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Password = passwordDigest
|
||||
user.PasswordDigest = passwordDigest
|
||||
user.ResetPasswordToken = ""
|
||||
user.ResetPasswordSentAt = nil
|
||||
user.ConfirmationToken = ""
|
||||
|
||||
return s.issueLoginOutput(ctx, &user)
|
||||
}
|
||||
|
||||
// --- Email Confirmation ---
|
||||
@@ -412,10 +466,30 @@ type ConfirmEmailInput struct {
|
||||
}
|
||||
|
||||
// ConfirmEmail verifies a confirmation token and marks user email as confirmed.
|
||||
func (s *AuthService) ConfirmEmail(ctx context.Context, input *ConfirmEmailInput) (*model.User, error) {
|
||||
// Production note: confirmation token must be verified from Redis before marking confirmed.
|
||||
// Placeholder — in development mode, users are auto-confirmed during registration
|
||||
return nil, fmt.Errorf("email confirmation not yet implemented (auto-confirmed in dev mode)")
|
||||
func (s *AuthService) ConfirmEmail(ctx context.Context, input *ConfirmEmailInput) (*LoginOutput, error) {
|
||||
token := strings.TrimSpace(input.Token)
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("Invalid token")
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.WithContext(ctx).Where("confirmation_token = ?", token).First(&user).Error; err != nil {
|
||||
return nil, fmt.Errorf("Invalid token")
|
||||
}
|
||||
if user.ConfirmedAt != nil {
|
||||
return nil, fmt.Errorf("Already confirmed")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := s.db.WithContext(ctx).Model(&user).Updates(map[string]interface{}{
|
||||
"confirmed_at": now,
|
||||
"confirmation_token": "",
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.ConfirmedAt = &now
|
||||
user.ConfirmationToken = ""
|
||||
|
||||
return s.issueLoginOutput(ctx, &user)
|
||||
}
|
||||
|
||||
// --- OAuth2 Login ---
|
||||
@@ -513,6 +587,41 @@ func (s *AuthService) getUserDefaultAccount(user *model.User) (uint, string, err
|
||||
return accountUser.AccountID, accountUser.Role, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) issueLoginOutput(ctx context.Context, user *model.User) (*LoginOutput, error) {
|
||||
accountID, role, err := s.getUserDefaultAccount(user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user account: %w", err)
|
||||
}
|
||||
tokenPair, err := s.jwtService.GenerateTokenPair(user, accountID, role)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate tokens: %w", err)
|
||||
}
|
||||
if err := s.refreshStore.Store(ctx, user.ID, tokenPair.RefreshToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to store refresh token: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
user.SignInCount++
|
||||
user.LastSignInAt = user.CurrentSignInAt
|
||||
user.CurrentSignInAt = &now
|
||||
if err := s.db.WithContext(ctx).Save(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginOutput{User: user, TokenPair: tokenPair, AccountID: accountID, Role: role}, nil
|
||||
}
|
||||
|
||||
func generateAuthToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func digestAuthToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// createDefaultAccount creates a personal account for a new user.
|
||||
func (s *AuthService) createDefaultAccount(user *model.User) (uint, string, error) {
|
||||
// Create account
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/auth"
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/pkg/crypto"
|
||||
)
|
||||
|
||||
func setupAuthServiceTest(t *testing.T) (*AuthService, *gorm.DB, *model.User) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}))
|
||||
account := &model.Account{Name: "Auth Service", Status: "active"}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
passwordDigest, err := crypto.HashPassword("oldpassword")
|
||||
require.NoError(t, err)
|
||||
user := &model.User{AccountID: account.ID, Name: "Auth User", Email: "auth-service@example.com", Password: passwordDigest, PasswordDigest: passwordDigest, Provider: "email", Active: true}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error)
|
||||
jwtCfg := &config.JWTConfig{Secret: "auth-service-secret", ExpiryHours: 1, RefreshExpiryHours: 24}
|
||||
return NewAuthService(db, auth.NewJWTService(jwtCfg), auth.NewRefreshTokenStore(nil, jwtCfg), nil, nil), db, user
|
||||
}
|
||||
|
||||
func TestAuthService_ResetPasswordStoresDigestToken(t *testing.T) {
|
||||
svc, db, user := setupAuthServiceTest(t)
|
||||
|
||||
require.NoError(t, svc.ResetPassword(context.Background(), &ResetPasswordInput{Email: " AUTH-SERVICE@example.com "}))
|
||||
|
||||
var updated model.User
|
||||
require.NoError(t, db.First(&updated, user.ID).Error)
|
||||
require.NotEmpty(t, updated.ResetPasswordToken)
|
||||
require.NotEqual(t, updated.ResetPasswordToken, "AUTH-SERVICE@example.com")
|
||||
require.NotNil(t, updated.ResetPasswordSentAt)
|
||||
}
|
||||
|
||||
func TestAuthService_ConfirmResetPasswordUpdatesPasswordAndIssuesTokens(t *testing.T) {
|
||||
svc, db, user := setupAuthServiceTest(t)
|
||||
rawToken := "reset-token-123"
|
||||
sentAt := time.Now().UTC()
|
||||
require.NoError(t, db.Model(user).Updates(map[string]interface{}{
|
||||
"reset_password_token": digestAuthToken(rawToken),
|
||||
"reset_password_sent_at": sentAt,
|
||||
}).Error)
|
||||
|
||||
output, err := svc.ConfirmResetPassword(context.Background(), &ConfirmResetPasswordInput{Token: rawToken, Password: "newpassword", PasswordConfirmation: "newpassword"})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, output.TokenPair.AccessToken)
|
||||
|
||||
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)
|
||||
require.Nil(t, updated.ResetPasswordSentAt)
|
||||
require.NotNil(t, updated.ConfirmedAt)
|
||||
}
|
||||
|
||||
func TestAuthService_ConfirmEmailConfirmsAndIssuesTokens(t *testing.T) {
|
||||
svc, db, user := setupAuthServiceTest(t)
|
||||
require.NoError(t, db.Model(user).Updates(map[string]interface{}{
|
||||
"confirmation_token": "confirm-token-123",
|
||||
"confirmed_at": nil,
|
||||
}).Error)
|
||||
|
||||
output, err := svc.ConfirmEmail(context.Background(), &ConfirmEmailInput{Token: "confirm-token-123"})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, output.TokenPair.AccessToken)
|
||||
|
||||
var updated model.User
|
||||
require.NoError(t, db.First(&updated, user.ID).Error)
|
||||
require.NotNil(t, updated.ConfirmedAt)
|
||||
require.Empty(t, updated.ConfirmationToken)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_users_confirmation_token;
|
||||
DROP INDEX IF EXISTS idx_users_reset_password_token;
|
||||
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS confirmation_sent_at;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS confirmation_token;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS reset_password_sent_at;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS reset_password_token;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS reset_password_token VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS reset_password_sent_at TIMESTAMP WITH TIME ZONE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS confirmation_token VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS confirmation_sent_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_reset_password_token ON users(reset_password_token) WHERE reset_password_token IS NOT NULL AND reset_password_token <> '';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_confirmation_token ON users(confirmation_token) WHERE confirmation_token IS NOT NULL AND confirmation_token <> '';
|
||||
Reference in New Issue
Block a user