e045e00fbf
Add self-service password change that re-hashes the credential and bumps a per-user session version so all existing access/refresh tokens are rejected. Session version is tracked in Redis with an in-memory fallback and enforced in middleware and refresh. Scope prompt rules and rule groups to a brand: new brand_id column (migrated to a "未归属" fallback brand for existing rows), brand-filtered queries/indexes, and brand-aware admin-web tabs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
207 lines
6.1 KiB
Go
207 lines
6.1 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func TestAuthServiceLoginPasswordDecryptsEncryptedPassword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
|
}))
|
|
cipher, err := auth.NewPasswordCipher(privatePEM, "tenant-test-key")
|
|
require.NoError(t, err)
|
|
|
|
encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("Test@123"), nil)
|
|
require.NoError(t, err)
|
|
|
|
svc := (&AuthService{}).WithPasswordCipher(cipher)
|
|
got, err := svc.loginPassword(LoginRequest{
|
|
EncryptedPassword: base64.StdEncoding.EncodeToString(encrypted),
|
|
PasswordKeyID: "tenant-test-key",
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Test@123", got)
|
|
}
|
|
|
|
func TestAuthServiceLoginPasswordAllowsPlainPasswordCompatibility(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := (&AuthService{}).loginPassword(LoginRequest{Password: "Test@123"})
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Test@123", got)
|
|
}
|
|
|
|
func TestAuthServiceLoginPasswordRejectsEncryptedWhenCipherMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := (&AuthService{}).loginPassword(LoginRequest{EncryptedPassword: "abc"})
|
|
|
|
require.Error(t, err)
|
|
appErr, ok := err.(*response.AppError)
|
|
require.True(t, ok)
|
|
require.Equal(t, "password_cipher_unavailable", appErr.Message)
|
|
}
|
|
|
|
func TestAuthServiceChangePasswordUpdatesHash(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
oldHash, err := bcrypt.GenerateFromPassword([]byte("OldPass@123"), bcrypt.MinCost)
|
|
require.NoError(t, err)
|
|
repo := &authRepoStub{
|
|
user: &repository.UserRecord{
|
|
ID: 12,
|
|
Phone: "13800000000",
|
|
PasswordHash: string(oldHash),
|
|
Status: "active",
|
|
},
|
|
}
|
|
sessions := auth.NewSessionStore(nil)
|
|
svc := &AuthService{repo: repo, sessions: sessions}
|
|
|
|
err = svc.ChangePassword(context.Background(), auth.Actor{UserID: 12}, ChangePasswordRequest{
|
|
OldPassword: "OldPass@123",
|
|
NewPassword: "NewPass@123",
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(12), repo.updatedUserID)
|
|
require.NoError(t, bcrypt.CompareHashAndPassword([]byte(repo.updatedPasswordHash), []byte("NewPass@123")))
|
|
version, err := sessions.SessionVersion(context.Background(), 12)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1), version)
|
|
}
|
|
|
|
func TestAuthServiceChangePasswordRejectsInvalidOldPassword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
oldHash, err := bcrypt.GenerateFromPassword([]byte("OldPass@123"), bcrypt.MinCost)
|
|
require.NoError(t, err)
|
|
repo := &authRepoStub{
|
|
user: &repository.UserRecord{
|
|
ID: 12,
|
|
Phone: "13800000000",
|
|
PasswordHash: string(oldHash),
|
|
Status: "active",
|
|
},
|
|
}
|
|
svc := &AuthService{repo: repo}
|
|
|
|
err = svc.ChangePassword(context.Background(), auth.Actor{UserID: 12}, ChangePasswordRequest{
|
|
OldPassword: "wrong",
|
|
NewPassword: "NewPass@123",
|
|
})
|
|
|
|
require.Error(t, err)
|
|
appErr, ok := err.(*response.AppError)
|
|
require.True(t, ok)
|
|
require.Equal(t, "invalid_old_password", appErr.Message)
|
|
require.Empty(t, repo.updatedPasswordHash)
|
|
}
|
|
|
|
func TestAuthServiceChangePasswordRejectsWeakPassword(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
repo := &authRepoStub{}
|
|
svc := &AuthService{repo: repo}
|
|
|
|
err := svc.ChangePassword(context.Background(), auth.Actor{UserID: 12}, ChangePasswordRequest{
|
|
OldPassword: "OldPass@123",
|
|
NewPassword: "short",
|
|
})
|
|
|
|
require.Error(t, err)
|
|
appErr, ok := err.(*response.AppError)
|
|
require.True(t, ok)
|
|
require.Equal(t, "weak_password", appErr.Message)
|
|
require.False(t, repo.getUserByIDCalled)
|
|
}
|
|
|
|
func TestAuthServiceChangePasswordDecryptsEncryptedPasswords(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
|
}))
|
|
cipher, err := auth.NewPasswordCipher(privatePEM, "tenant-change-key")
|
|
require.NoError(t, err)
|
|
|
|
encryptedOld, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("OldPass@123"), nil)
|
|
require.NoError(t, err)
|
|
encryptedNew, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("NewPass@123"), nil)
|
|
require.NoError(t, err)
|
|
|
|
svc := (&AuthService{}).WithPasswordCipher(cipher)
|
|
oldPassword, newPassword, err := svc.changePasswordValues(ChangePasswordRequest{
|
|
EncryptedOldPassword: base64.StdEncoding.EncodeToString(encryptedOld),
|
|
EncryptedNewPassword: base64.StdEncoding.EncodeToString(encryptedNew),
|
|
PasswordKeyID: "tenant-change-key",
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, "OldPass@123", oldPassword)
|
|
require.Equal(t, "NewPass@123", newPassword)
|
|
}
|
|
|
|
type authRepoStub struct {
|
|
user *repository.UserRecord
|
|
getUserByIDCalled bool
|
|
updatedUserID int64
|
|
updatedPasswordHash string
|
|
}
|
|
|
|
func (r *authRepoStub) GetUserByEmail(context.Context, string) (*repository.UserRecord, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
|
|
func (r *authRepoStub) GetUserByLoginIdentifier(context.Context, string) (*repository.UserRecord, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
|
|
func (r *authRepoStub) GetUserByID(context.Context, int64) (*repository.UserRecord, error) {
|
|
r.getUserByIDCalled = true
|
|
if r.user == nil {
|
|
return nil, errors.New("not found")
|
|
}
|
|
return r.user, nil
|
|
}
|
|
|
|
func (r *authRepoStub) UpdateUserPassword(_ context.Context, id int64, passwordHash string) error {
|
|
r.updatedUserID = id
|
|
r.updatedPasswordHash = passwordHash
|
|
return nil
|
|
}
|
|
|
|
func (r *authRepoStub) GetTenantMembership(context.Context, int64) (*repository.MembershipRecord, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
|
|
func (r *authRepoStub) GetTenantMembershipByTenantAndUser(context.Context, int64, int64) (*repository.MembershipRecord, error) {
|
|
return nil, errors.New("not implemented")
|
|
}
|
|
|
|
var _ repository.AuthRepository = (*authRepoStub)(nil)
|