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:
119
auth/login.go
Normal file
119
auth/login.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user