feat: implement user profile management with avatars, login, and personal data

- Add avatar upload functionality with public URL access.
- Implement user login with email and password authentication.
- Create endpoints for fetching and updating the authenticated user's profile.
- Set up database migrations for user profiles, including email and role management.
- Introduce personal data management linked to user profiles.
- Add email verification process with welcome email templates.
This commit is contained in:
fabio
2026-07-02 10:39:53 +02:00
parent 2608ce6e60
commit 167be9b0b3
94 changed files with 11696 additions and 1 deletions

119
auth/login.go Normal file
View File

@@ -0,0 +1,119 @@
package auth
import (
"context"
"errors"
"time"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/pubsub"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
"golang.org/x/crypto/bcrypt"
)
type LoginParams struct {
UserID uuid.UUID `json:"user_id"`
Password string `json:"password" encore:"sensitive"`
}
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 := bcrypt.CompareHashAndPassword([]byte(hash), pepperedPassword(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
}
// 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
}
_, err := LoginResults.Publish(ctx, result)
return err
}