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

110
auth/auth.go Normal file
View File

@@ -0,0 +1,110 @@
// Service auth owns authentication: credentials, sessions, login and logout.
package auth
import (
"context"
"crypto/sha256"
"errors"
"net/http"
"strings"
"time"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid"
"golang.org/x/crypto/bcrypt"
)
var db = sqldb.NewDatabase("auth", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
var profilesDB = sqldb.Named("profiles")
// PasswordPepper is mixed into every password before hashing, on top of
// bcrypt's per-password salt, so leaked password hashes are useless without it.
var secrets struct {
PasswordPepper string
}
const sessionTTL = 7 * 24 * time.Hour
type AuthParams struct {
SessionCookie *http.Cookie `cookie:"session"`
Authorization string `header:"Authorization"`
}
// AuthData is exposed to authenticated handlers via auth.Data().
type AuthData struct {
SessionToken uuid.UUID
Role role
}
// AuthHandler authenticates a request by looking up its session token — taken
// from the session cookie, or as a fallback the "Bearer <token>" Authorization
// header — against active sessions.
//
//encore:authhandler
func AuthHandler(ctx context.Context, p *AuthParams) (auth.UID, *AuthData, error) {
token, ok := sessionToken(p)
if !ok {
return "", nil, &errs.Error{Code: errs.Unauthenticated, Message: "missing session credentials"}
}
var userID uuid.UUID
err := db.QueryRow(ctx, `
SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()
`, token).Scan(&userID)
if errors.Is(err, sqldb.ErrNoRows) {
return "", nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid or expired session"}
}
if err != nil {
return "", nil, errs.WrapCode(err, errs.Internal, "failed to validate session")
}
var roleStr string
err = profilesDB.QueryRow(ctx, `
SELECT role FROM user_profiles WHERE user_id = $1
`, userID).Scan(&roleStr)
if err != nil && !errors.Is(err, sqldb.ErrNoRows) {
return "", nil, errs.WrapCode(err, errs.Internal, "failed to load user role")
}
r := role(roleStr)
if !r.IsValid() {
r = roleUser
}
return auth.UID(userID.String()), &AuthData{SessionToken: token, Role: r}, nil
}
func sessionToken(p *AuthParams) (uuid.UUID, bool) {
if p.SessionCookie != nil {
if id, err := uuid.FromString(p.SessionCookie.Value); err == nil {
return id, true
}
}
if rest, ok := strings.CutPrefix(p.Authorization, "Bearer "); ok {
if id, err := uuid.FromString(rest); err == nil {
return id, true
}
}
return uuid.Nil, false
}
// pepperedPassword pre-hashes the password together with the application-wide
// 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 {
sum := sha256.Sum256([]byte(password + secrets.PasswordPepper))
return sum[:]
}
func hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword(pepperedPassword(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}

62
auth/credentials.go Normal file
View File

@@ -0,0 +1,62 @@
package auth
import (
"context"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/types/uuid"
)
type RegisterParams struct {
UserID uuid.UUID `json:"user_id"`
Password string `json:"password" encore:"sensitive"`
}
// 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.
//
//encore:api private method=POST path=/auth/credentials
func Register(ctx context.Context, p *RegisterParams) error {
hash, err := hashPassword(p.Password)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to hash password")
}
_, err = db.Exec(ctx, `
INSERT INTO credentials (user_id, password_hash) VALUES ($1, $2)
`, p.UserID, hash)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to store credentials")
}
return nil
}
type SetPasswordParams struct {
Password string `json:"password" encore:"sensitive"`
}
// SetPassword changes the password for the authenticated user.
//
//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))
if err != nil {
return errs.WrapCode(err, errs.Internal, "invalid user id")
}
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
}

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
}

View File

@@ -0,0 +1,15 @@
CREATE TABLE credentials (
user_id UUID PRIMARY KEY,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE sessions (
token UUID PRIMARY KEY,
user_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX sessions_user_id_idx ON sessions (user_id);

43
auth/roles.go Normal file
View File

@@ -0,0 +1,43 @@
package auth
type role string
const (
roleUser role = "user"
roleAdmin role = "admin"
)
func (r role) String() string {
return string(r)
}
func (r role) IsValid() bool {
return r == roleUser || r == roleAdmin
}
func (r role) IsAdmin() bool {
return r == roleAdmin
}
func (r role) IsUser() bool {
return r == roleUser
}
// RoleOption describes a valid role for display in admin UIs.
type RoleOption struct {
Name string `json:"name"`
Value string `json:"value"`
}
// Roles returns every valid role with a display name and its underlying value.
func Roles() []RoleOption {
return []RoleOption{
{Name: "User", Value: string(roleUser)},
{Name: "Admin", Value: string(roleAdmin)},
}
}
// IsValidRole reports whether value is a valid role.
func IsValidRole(value string) bool {
return role(value).IsValid()
}