- 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.
185 lines
5.4 KiB
Go
185 lines
5.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"encore.dev/beta/auth"
|
|
"encore.dev/beta/errs"
|
|
"encore.dev/cron"
|
|
"encore.dev/pubsub"
|
|
"encore.dev/storage/sqldb"
|
|
"encore.dev/types/uuid"
|
|
)
|
|
|
|
type LoginParams struct {
|
|
UserID uuid.UUID `json:"user_id"`
|
|
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"`
|
|
}
|
|
|
|
// Login verifies a user's password and issues a new session token.
|
|
//
|
|
// It is private — external clients authenticate via the public
|
|
// profiles.Login, which talks to this service asynchronously over Pub/Sub
|
|
// (see LoginRequests/LoginResults below) rather than calling it directly.
|
|
//
|
|
//encore:api private method=POST path=/auth/internal/login
|
|
func Login(ctx context.Context, p *LoginParams) (*LoginResponse, error) {
|
|
var hash string
|
|
err := db.QueryRow(ctx, `
|
|
SELECT password_hash FROM credentials WHERE user_id = $1
|
|
`, p.UserID).Scan(&hash)
|
|
if errors.Is(err, sqldb.ErrNoRows) {
|
|
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
|
}
|
|
if err != nil {
|
|
return nil, errs.WrapCode(err, errs.Internal, "failed to fetch credentials")
|
|
}
|
|
if err := verifyPassword(hash, p.Password); err != nil {
|
|
return nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid credentials"}
|
|
}
|
|
|
|
token, err := uuid.NewV4()
|
|
if err != nil {
|
|
return nil, errs.WrapCode(err, errs.Internal, "failed to generate session token")
|
|
}
|
|
expiresAt := time.Now().Add(sessionTTL)
|
|
_, err = db.Exec(ctx, `
|
|
INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, $3)
|
|
`, token, p.UserID, expiresAt)
|
|
if err != nil {
|
|
return nil, errs.WrapCode(err, errs.Internal, "failed to create session")
|
|
}
|
|
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
|
|
func Logout(ctx context.Context) error {
|
|
data, _ := auth.Data().(*AuthData)
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
_, err := db.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, data.SessionToken)
|
|
if err != nil {
|
|
return errs.WrapCode(err, errs.Internal, "failed to revoke session")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoginRequested is published by profiles.Login to ask this service to
|
|
// authenticate a user asynchronously — the public endpoint lives in profiles,
|
|
// but the credential check and session issuance happen here.
|
|
type LoginRequested struct {
|
|
RequestID uuid.UUID
|
|
UserID uuid.UUID
|
|
Password string `encore:"sensitive"`
|
|
}
|
|
|
|
var LoginRequests = pubsub.NewTopic[*LoginRequested]("login-requests", pubsub.TopicConfig{
|
|
DeliveryGuarantee: pubsub.AtLeastOnce,
|
|
})
|
|
|
|
// LoginCompleted carries the outcome of a LoginRequested message back to
|
|
// whichever profiles instance is waiting on the matching RequestID.
|
|
type LoginCompleted struct {
|
|
RequestID uuid.UUID
|
|
OK bool
|
|
Token uuid.UUID
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
var LoginResults = pubsub.NewTopic[*LoginCompleted]("login-results", pubsub.TopicConfig{
|
|
DeliveryGuarantee: pubsub.AtLeastOnce,
|
|
})
|
|
|
|
var _ = pubsub.NewSubscription(
|
|
LoginRequests, "authenticate",
|
|
pubsub.SubscriptionConfig[*LoginRequested]{
|
|
Handler: handleLoginRequested,
|
|
},
|
|
)
|
|
|
|
func handleLoginRequested(ctx context.Context, ev *LoginRequested) error {
|
|
result := &LoginCompleted{RequestID: ev.RequestID}
|
|
if resp, err := Login(ctx, &LoginParams{UserID: ev.UserID, Password: ev.Password}); err == nil {
|
|
result.OK = true
|
|
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
|
|
}
|