feat(auth): align password reset flows

This commit is contained in:
2026-06-05 22:59:15 +08:00
parent 9db133697e
commit ad5d864944
8 changed files with 397 additions and 54 deletions
File diff suppressed because one or more lines are too long
+67 -4
View File
@@ -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) {
+73 -5
View File
@@ -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
View File
@@ -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" }
+125 -16
View File
@@ -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
+83
View File
@@ -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 <> '';