refactor: remove unused Pinia stores and related code
- Deleted the layout-store, profiles-store, and index.ts files from the Pinia store setup. - Removed the zod-sync script as it is no longer needed. - Cleaned up the tsconfig.json file by removing unnecessary references. - Updated the login logic in the profiles package to use polling for login results instead of in-process waiters. - Added validation methods for personal data and profile parameters. - Introduced new tests for validation logic in profiles and registration packages. - Enhanced email normalization and validation in the registration package.
This commit is contained in:
57
auth/auth.go
57
auth/auth.go
@@ -29,6 +29,7 @@ var secrets struct {
|
||||
}
|
||||
|
||||
const sessionTTL = 7 * 24 * time.Hour
|
||||
const minPasswordLength = 8
|
||||
|
||||
type AuthParams struct {
|
||||
SessionCookie *http.Cookie `cookie:"session"`
|
||||
@@ -96,13 +97,63 @@ func sessionToken(p *AuthParams) (uuid.UUID, bool) {
|
||||
// pepper secret using SHA-256, producing a fixed-size digest. This both mixes
|
||||
// in the pepper and keeps the input to bcrypt within its 72-byte limit
|
||||
// regardless of the original password's length.
|
||||
func pepperedPassword(password string) []byte {
|
||||
func pepperedPassword(password string) ([]byte, error) {
|
||||
if strings.TrimSpace(secrets.PasswordPepper) == "" {
|
||||
return nil, &errs.Error{Code: errs.Internal, Message: "password pepper is not configured"}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(password + secrets.PasswordPepper))
|
||||
return sum[:]
|
||||
return sum[:], nil
|
||||
}
|
||||
|
||||
func validatePassword(password string) error {
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "password is required"}
|
||||
}
|
||||
if len(password) < minPasswordLength {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "password must be at least 8 characters long"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePassword exposes the shared password rules to other services.
|
||||
func ValidatePassword(password string) error {
|
||||
return validatePassword(password)
|
||||
}
|
||||
|
||||
func authenticatedUserID() (uuid.UUID, error) {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return uuid.Nil, &errs.Error{Code: errs.Unauthenticated, Message: "missing auth"}
|
||||
}
|
||||
return parseUserID(uid)
|
||||
}
|
||||
|
||||
func parseUserID(uid auth.UID) (uuid.UUID, error) {
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
if err != nil {
|
||||
return uuid.Nil, errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func verifyPassword(hash, password string) error {
|
||||
peppered, err := pepperedPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), peppered)
|
||||
}
|
||||
|
||||
func isPasswordReuse(currentHash, newPassword string) bool {
|
||||
return verifyPassword(currentHash, newPassword) == nil
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword(pepperedPassword(password), bcrypt.DefaultCost)
|
||||
peppered, err := pepperedPassword(password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword(peppered, bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type RegisterParams struct {
|
||||
@@ -16,6 +14,16 @@ type RegisterParams struct {
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
func (p *RegisterParams) Validate() error {
|
||||
if p == nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "register params are required"}
|
||||
}
|
||||
if p.UserID == uuid.Nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "user id is required"}
|
||||
}
|
||||
return validatePassword(p.Password)
|
||||
}
|
||||
|
||||
// Register stores the initial password hash for a newly created profile.
|
||||
// Called directly (service-to-service) by profiles.Insert during registration,
|
||||
// since that flow needs to know immediately whether credential setup succeeded.
|
||||
@@ -35,6 +43,23 @@ func Register(ctx context.Context, p *RegisterParams) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SetPasswordParams) Validate() error {
|
||||
if p == nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "password params are required"}
|
||||
}
|
||||
if err := validatePassword(p.CurrentPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePassword(p.Password)
|
||||
}
|
||||
|
||||
func (p *SetUserPasswordParams) Validate() error {
|
||||
if p == nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "user password params are required"}
|
||||
}
|
||||
return validatePassword(p.Password)
|
||||
}
|
||||
|
||||
type SetPasswordParams struct {
|
||||
CurrentPassword string `json:"current_password" encore:"sensitive"`
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
@@ -49,40 +74,11 @@ type SetUserPasswordParams struct {
|
||||
//
|
||||
//encore:api auth method=PUT path=/auth/password
|
||||
func SetPassword(ctx context.Context, p *SetPasswordParams) error {
|
||||
uid, ok := auth.UserID()
|
||||
if !ok {
|
||||
return &errs.Error{Code: errs.Unauthenticated, Message: "missing auth"}
|
||||
}
|
||||
userID, err := uuid.FromString(string(uid))
|
||||
userID, err := authenticatedUserID()
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||
return err
|
||||
}
|
||||
|
||||
var currentHash string
|
||||
err = db.QueryRow(ctx, `
|
||||
SELECT password_hash FROM credentials WHERE user_id = $1
|
||||
`, userID).Scan(¤tHash)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to fetch credentials")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), pepperedPassword(p.CurrentPassword)); err != nil {
|
||||
return &errs.Error{Code: errs.Unauthenticated, Message: "invalid current password"}
|
||||
}
|
||||
|
||||
hash, err := hashPassword(p.Password)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to hash password")
|
||||
}
|
||||
_, err = db.Exec(ctx, `
|
||||
UPDATE credentials SET password_hash = $2, updated_at = NOW() WHERE user_id = $1
|
||||
`, userID, hash)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to update password")
|
||||
}
|
||||
return nil
|
||||
return setPasswordWithCurrentCheck(ctx, userID, p.CurrentPassword, p.Password)
|
||||
}
|
||||
|
||||
// SetUserPassword changes a user's password from trusted internal flows.
|
||||
@@ -92,13 +88,77 @@ func SetUserPassword(ctx context.Context, userID uuid.UUID, p *SetUserPasswordPa
|
||||
if p.UserID != uuid.Nil && p.UserID != userID {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "user id mismatch"}
|
||||
}
|
||||
return replacePassword(ctx, userID, p.Password)
|
||||
}
|
||||
|
||||
hash, err := hashPassword(p.Password)
|
||||
func setPasswordWithCurrentCheck(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
|
||||
if err := validatePassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.Begin(ctx)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to begin password update transaction")
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var currentHash string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT password_hash FROM credentials WHERE user_id = $1 FOR UPDATE
|
||||
`, userID).Scan(¤tHash)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to fetch credentials")
|
||||
}
|
||||
if err = verifyPassword(currentHash, currentPassword); err != nil {
|
||||
return &errs.Error{Code: errs.Unauthenticated, Message: "invalid current password"}
|
||||
}
|
||||
if isPasswordReuse(currentHash, newPassword) {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "new password must be different from the current password"}
|
||||
}
|
||||
|
||||
hash, err := hashPassword(newPassword)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to hash password")
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE credentials SET password_hash = $2, updated_at = NOW() WHERE user_id = $1
|
||||
`, userID, hash)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to update password")
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to commit password update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
res, err := db.Exec(ctx, `
|
||||
func replacePassword(ctx context.Context, userID uuid.UUID, newPassword string) error {
|
||||
if err := validatePassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.Begin(ctx)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to begin password update transaction")
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
hash, err := hashPassword(newPassword)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to hash password")
|
||||
}
|
||||
res, err := tx.Exec(ctx, `
|
||||
UPDATE credentials SET password_hash = $2, updated_at = NOW() WHERE user_id = $1
|
||||
`, userID, hash)
|
||||
if err != nil {
|
||||
@@ -107,5 +167,8 @@ func SetUserPassword(ctx context.Context, userID uuid.UUID, p *SetUserPasswordPa
|
||||
if res.RowsAffected() == 0 {
|
||||
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to commit password update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/cron"
|
||||
"encore.dev/pubsub"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type LoginParams struct {
|
||||
@@ -18,6 +18,16 @@ type LoginParams struct {
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
func (p *LoginParams) Validate() error {
|
||||
if p == nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "login params are required"}
|
||||
}
|
||||
if p.UserID == uuid.Nil {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "user id is required"}
|
||||
}
|
||||
return validatePassword(p.Password)
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token uuid.UUID `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
@@ -41,7 +51,7 @@ func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch credentials")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), pepperedPassword(p.Password)); err != nil {
|
||||
if err := verifyPassword(hash, p.Password); err != nil {
|
||||
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
||||
}
|
||||
|
||||
@@ -59,6 +69,42 @@ func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
|
||||
return &LoginResponse{Token: token, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
var _ = cron.NewJob("cleanup-expired-sessions", cron.JobConfig{
|
||||
Title: "Cleanup expired sessions",
|
||||
Every: 1 * cron.Hour,
|
||||
Endpoint: CleanupExpiredSessions,
|
||||
})
|
||||
|
||||
var _ = cron.NewJob("cleanup-older-login-results", cron.JobConfig{
|
||||
Title: "Cleanup stale login results",
|
||||
Every: 6 * cron.Hour,
|
||||
Endpoint: CleanupLoginResults,
|
||||
})
|
||||
|
||||
// CleanupExpiredSessions removes expired sessions from the database.
|
||||
//
|
||||
//encore:api private
|
||||
func CleanupExpiredSessions(ctx context.Context) error {
|
||||
_, err := db.Exec(ctx, `DELETE FROM sessions WHERE expires_at <= NOW()`)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to purge expired sessions")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupLoginResults removes stale async auth correlation rows that are no longer needed.
|
||||
//
|
||||
//encore:api private
|
||||
func CleanupLoginResults(ctx context.Context) error {
|
||||
_, err := db.Exec(ctx, `
|
||||
DELETE FROM login_results WHERE created_at < NOW() - INTERVAL '24 hours'
|
||||
`)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to purge stale login results")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout revokes the session used to authenticate the current request.
|
||||
//
|
||||
//encore:api auth method=POST path=/auth/logout
|
||||
@@ -114,6 +160,25 @@ func handleLoginRequested(ctx context.Context, ev *LoginRequested) error {
|
||||
result.Token = resp.Token
|
||||
result.ExpiresAt = resp.ExpiresAt
|
||||
}
|
||||
if err := storeLoginResult(ctx, result); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := LoginResults.Publish(ctx, result)
|
||||
return err
|
||||
}
|
||||
|
||||
func storeLoginResult(ctx context.Context, result *LoginCompleted) error {
|
||||
_, err := db.Exec(ctx, `
|
||||
INSERT INTO login_results (request_id, ok, token, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (request_id) DO UPDATE SET
|
||||
ok = EXCLUDED.ok,
|
||||
token = EXCLUDED.token,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
updated_at = NOW()
|
||||
`, result.RequestID, result.OK, result.Token, result.ExpiresAt)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to store login result")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
10
auth/migrations/2_create_login_results.up.sql
Normal file
10
auth/migrations/2_create_login_results.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE login_results (
|
||||
request_id UUID PRIMARY KEY,
|
||||
ok BOOLEAN NOT NULL,
|
||||
token UUID,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX login_results_created_at_idx ON login_results (created_at);
|
||||
1
auth/migrations/3_add_expired_sessions_idx.up.sql
Normal file
1
auth/migrations/3_add_expired_sessions_idx.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE INDEX sessions_expires_at_idx ON sessions (expires_at);
|
||||
111
auth/password_validation_test.go
Normal file
111
auth/password_validation_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
authpkg "encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/types/uuid"
|
||||
)
|
||||
|
||||
func TestValidatePasswordRejectsShortAndEmptyValues(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
password string
|
||||
}{
|
||||
{name: "empty", password: ""},
|
||||
{name: "spaces", password: " "},
|
||||
{name: "short", password: "short"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validatePassword(tc.password)
|
||||
if err == nil {
|
||||
t.Fatalf("validatePassword(%q) = nil, want error", tc.password)
|
||||
}
|
||||
e, ok := err.(*errs.Error)
|
||||
if !ok {
|
||||
t.Fatalf("validatePassword(%q) returned %T, want *errs.Error", tc.password, err)
|
||||
}
|
||||
if e.Code != errs.InvalidArgument {
|
||||
t.Fatalf("validatePassword(%q) returned code %v, want %v", tc.password, e.Code, errs.InvalidArgument)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginParamsValidate(t *testing.T) {
|
||||
uid, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatalf("uuid.NewV4() failed: %v", err)
|
||||
}
|
||||
|
||||
if err := (&LoginParams{UserID: uuid.Nil, Password: "validpass"}).Validate(); err == nil {
|
||||
t.Fatal("Validate() should reject nil user id")
|
||||
}
|
||||
if err := (&LoginParams{UserID: uid, Password: "short"}).Validate(); err == nil {
|
||||
t.Fatal("Validate() should reject short password")
|
||||
}
|
||||
if err := (&LoginParams{UserID: uid, Password: "validpass"}).Validate(); err != nil {
|
||||
t.Fatalf("Validate() returned unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterParamsValidate(t *testing.T) {
|
||||
uid, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatalf("uuid.NewV4() failed: %v", err)
|
||||
}
|
||||
|
||||
if err := (&RegisterParams{UserID: uuid.Nil, Password: "validpass"}).Validate(); err == nil {
|
||||
t.Fatal("Validate() should reject nil user id")
|
||||
}
|
||||
if err := (&RegisterParams{UserID: uid, Password: "weak"}).Validate(); err == nil {
|
||||
t.Fatal("Validate() should reject weak password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPepperedPasswordRequiresConfiguredSecret(t *testing.T) {
|
||||
old := secrets.PasswordPepper
|
||||
secrets.PasswordPepper = ""
|
||||
defer func() { secrets.PasswordPepper = old }()
|
||||
|
||||
if _, err := pepperedPassword("validpass"); err == nil {
|
||||
t.Fatal("pepperedPassword() should fail when PasswordPepper is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordReuseDetection(t *testing.T) {
|
||||
old := secrets.PasswordPepper
|
||||
secrets.PasswordPepper = "pepper-test"
|
||||
defer func() { secrets.PasswordPepper = old }()
|
||||
|
||||
hash, err := hashPassword("this-is-a-valid-password")
|
||||
if err != nil {
|
||||
t.Fatalf("hashPassword() failed: %v", err)
|
||||
}
|
||||
if !isPasswordReuse(hash, "this-is-a-valid-password") {
|
||||
t.Fatal("isPasswordReuse() should return true for the same password")
|
||||
}
|
||||
if isPasswordReuse(hash, "another-very-different-pass") {
|
||||
t.Fatal("isPasswordReuse() should return false for a different password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUserID(t *testing.T) {
|
||||
uid, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatalf("uuid.NewV4() failed: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := parseUserID(authpkg.UID(uid.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("parseUserID() returned unexpected error: %v", err)
|
||||
}
|
||||
if parsed != uid {
|
||||
t.Fatalf("parseUserID() mismatch: got %v, want %v", parsed, uid)
|
||||
}
|
||||
|
||||
if _, err := parseUserID(authpkg.UID("not-a-uuid")); err == nil {
|
||||
t.Fatal("parseUserID() should reject invalid UUID strings")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user