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:
110
auth/auth.go
Normal file
110
auth/auth.go
Normal 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
62
auth/credentials.go
Normal 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
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
|
||||
}
|
||||
15
auth/migrations/1_create_auth_tables.up.sql
Normal file
15
auth/migrations/1_create_auth_tables.up.sql
Normal 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
43
auth/roles.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user