login via magic link

This commit is contained in:
juancwu 2026-01-04 19:24:01 -05:00
commit 94a05b0433
22 changed files with 815 additions and 122 deletions

View file

@ -19,6 +19,5 @@ MAILER_IMAP_PORT=
MAILER_USERNAME= MAILER_USERNAME=
MAILER_PASSWORD= MAILER_PASSWORD=
MAILER_EMAIL_FROM= MAILER_EMAIL_FROM=
MAILER_ENVELOPE_FROM=
MAILER_SUPPORT_EMAIL= SUPPORT_EMAIL=
MAILER_SUPPORT_ENVELOPE_FROM=

View file

@ -11,11 +11,12 @@ import (
) )
type App struct { type App struct {
Cfg *config.Config Cfg *config.Config
DB *sqlx.DB DB *sqlx.DB
UserService *service.UserService UserService *service.UserService
AuthService *service.AuthService AuthService *service.AuthService
EmailService *service.EmailService EmailService *service.EmailService
ProfileService *service.ProfileService
} }
func New(cfg *config.Config) (*App, error) { func New(cfg *config.Config) (*App, error) {
@ -32,17 +33,36 @@ func New(cfg *config.Config) (*App, error) {
emailClient := service.NewEmailClient(cfg.MailerSMTPHost, cfg.MailerSMTPPort, cfg.MailerIMAPHost, cfg.MailerIMAPPort, cfg.MailerUsername, cfg.MailerPassword) emailClient := service.NewEmailClient(cfg.MailerSMTPHost, cfg.MailerSMTPPort, cfg.MailerIMAPHost, cfg.MailerIMAPPort, cfg.MailerUsername, cfg.MailerPassword)
userRepository := repository.NewUserRepository(database) userRepository := repository.NewUserRepository(database)
profileRepository := repository.NewProfileRepository(database)
tokenRepository := repository.NewTokenRepository(database)
userService := service.NewUserService(userRepository) userService := service.NewUserService(userRepository)
authService := service.NewAuthService(userRepository) emailService := service.NewEmailService(
emailService := service.NewEmailService(emailClient, cfg.MailerEmailFrom, cfg.MailerEnvelopeFrom, cfg.MailerSupportFrom, cfg.MailerSupportEnvelopeFrom, cfg.AppURL, cfg.AppName, cfg.AppEnv == "development") emailClient,
cfg.MailerEmailFrom,
cfg.AppURL,
cfg.AppName,
cfg.IsProduction(),
)
authService := service.NewAuthService(
emailService,
userRepository,
profileRepository,
tokenRepository,
cfg.JWTSecret,
cfg.JWTExpiry,
cfg.TokenMagicLinkExpiry,
cfg.IsProduction(),
)
profileService := service.NewProfileService(profileRepository)
return &App{ return &App{
Cfg: cfg, Cfg: cfg,
DB: database, DB: database,
UserService: userService, UserService: userService,
AuthService: authService, AuthService: authService,
EmailService: emailService, EmailService: emailService,
ProfileService: profileService,
}, nil }, nil
} }

View file

@ -20,19 +20,19 @@ type Config struct {
DBDriver string DBDriver string
DBConnection string DBConnection string
JWTSecret string JWTSecret string
JWTExpiry time.Duration JWTExpiry time.Duration
TokenMagicLinkExpiry time.Duration
MailerSMTPHost string MailerSMTPHost string
MailerSMTPPort int MailerSMTPPort int
MailerIMAPHost string MailerIMAPHost string
MailerIMAPPort int MailerIMAPPort int
MailerUsername string MailerUsername string
MailerPassword string MailerPassword string
MailerEmailFrom string MailerEmailFrom string
MailerEnvelopeFrom string
MailerSupportFrom string SupportEmail string
MailerSupportEnvelopeFrom string
} }
func Load() *Config { func Load() *Config {
@ -52,19 +52,19 @@ func Load() *Config {
DBDriver: envString("DB_DRIVER", "sqlite"), DBDriver: envString("DB_DRIVER", "sqlite"),
DBConnection: envString("DB_CONNECTION", "./data/local.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)"), DBConnection: envString("DB_CONNECTION", "./data/local.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)"),
JWTSecret: envRequired("JWT_SECRET"), JWTSecret: envRequired("JWT_SECRET"),
JWTExpiry: envDuration("JWT_EXPIRY", 168*time.Hour), // 7 days default JWTExpiry: envDuration("JWT_EXPIRY", 168*time.Hour), // 7 days default
TokenMagicLinkExpiry: envDuration("TOKEN_MAGIC_LINK_EXPIRY", 10*time.Minute),
MailerSMTPHost: envString("MAILER_SMTP_HOST", ""), MailerSMTPHost: envString("MAILER_SMTP_HOST", ""),
MailerSMTPPort: envInt("MAILER_SMTP_PORT", 587), MailerSMTPPort: envInt("MAILER_SMTP_PORT", 587),
MailerIMAPHost: envString("MAILER_IMAP_HOST", ""), MailerIMAPHost: envString("MAILER_IMAP_HOST", ""),
MailerIMAPPort: envInt("MAILER_IMAP_PORT", 993), MailerIMAPPort: envInt("MAILER_IMAP_PORT", 993),
MailerUsername: envString("MAILER_USERNAME", ""), MailerUsername: envString("MAILER_USERNAME", ""),
MailerPassword: envString("MAILER_PASSWORD", ""), MailerPassword: envString("MAILER_PASSWORD", ""),
MailerEmailFrom: envString("MAILER_EMAIL_FROM", ""), MailerEmailFrom: envString("MAILER_EMAIL_FROM", ""),
MailerEnvelopeFrom: envString("MAILER_ENVELOPE_FROM", ""),
MailerSupportFrom: envString("MAILER_SUPPORT_EMAIL_FROM", ""), SupportEmail: envString("SUPPORT_EMAIL", ""),
MailerSupportEnvelopeFrom: envString("MAILER_SUPPORT_ENVELOPE_FROM", ""),
} }
return cfg return cfg
@ -85,8 +85,8 @@ func (c *Config) Sanitized() *Config {
Port: c.Port, Port: c.Port,
AppTagline: c.AppTagline, AppTagline: c.AppTagline,
MailerEmailFrom: c.MailerEmailFrom, MailerEmailFrom: c.MailerEmailFrom,
MailerEnvelopeFrom: c.MailerEnvelopeFrom, SupportEmail: c.SupportEmail,
} }
} }

View file

@ -1,7 +1,7 @@
-- +goose Up -- +goose Up
-- +goose StatementBegin -- +goose StatementBegin
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
password_hash TEXT NULL, -- Allow null for passwordless login password_hash TEXT NULL, -- Allow null for passwordless login
pending_email TEXT NULL, -- Store new email when changing email pending_email TEXT NULL, -- Store new email when changing email

View file

@ -1,8 +1,8 @@
-- +goose Up -- +goose Up
-- +goose StatementBegin -- +goose StatementBegin
CREATE TABLE IF NOT EXISTS tokens ( CREATE TABLE IF NOT EXISTS tokens (
id SERIAL PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL, user_id TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
token TEXT UNIQUE NOT NULL, token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL, expires_at TIMESTAMP NOT NULL,

View file

@ -1,8 +1,8 @@
-- +goose Up -- +goose Up
-- +goose StatementBegin -- +goose StatementBegin
CREATE TABLE IF NOT EXISTS profiles ( CREATE TABLE IF NOT EXISTS profiles (
id SERIAL PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
user_id INTEGER UNIQUE NOT NULL, user_id TEXT UNIQUE NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

View file

@ -1,8 +1,8 @@
-- +goose Up -- +goose Up
-- +goose StatementBegin -- +goose StatementBegin
CREATE TABLE IF NOT EXISTS files ( CREATE TABLE IF NOT EXISTS files (
id SERIAL PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL, user_id TEXT NOT NULL,
owner_type TEXT NOT NULL, owner_type TEXT NOT NULL,
owner_id TEXT NOT NULL, owner_id TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,

View file

@ -1,17 +1,24 @@
package handler package handler
import ( import (
"log/slog"
"net/http" "net/http"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui" "git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages" "git.juancwu.dev/juancwu/budgit/internal/ui/pages"
"git.juancwu.dev/juancwu/budgit/internal/validation"
) )
type authHandler struct { type authHandler struct {
authService *service.AuthService
} }
func NewAuthHandler() *authHandler { func NewAuthHandler(authService *service.AuthService) *authHandler {
return &authHandler{} return &authHandler{authService: authService}
} }
func (h *authHandler) AuthPage(w http.ResponseWriter, r *http.Request) { func (h *authHandler) AuthPage(w http.ResponseWriter, r *http.Request) {
@ -21,3 +28,62 @@ func (h *authHandler) AuthPage(w http.ResponseWriter, r *http.Request) {
func (h *authHandler) PasswordPage(w http.ResponseWriter, r *http.Request) { func (h *authHandler) PasswordPage(w http.ResponseWriter, r *http.Request) {
ui.Render(w, r, pages.AuthPassword("")) ui.Render(w, r, pages.AuthPassword(""))
} }
func (h *authHandler) SendMagicLink(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
ui.Render(w, r, pages.Auth("Email is required"))
return
}
err := validation.ValidateEmail(email)
if err != nil {
ui.Render(w, r, pages.Auth("Please provide a valid email address"))
return
}
err = h.authService.SendMagicLink(email)
if err != nil {
slog.Warn("magic link send failed", "error", err, "email", email)
}
if r.URL.Query().Get("resend") == "true" {
ui.RenderOOB(w, r, toast.Toast(toast.Props{
Title: "Magic link sent",
Description: "Check your email for a new magic link",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}), "beforeend:#toast-container")
return
}
ui.Render(w, r, pages.MagicLinkSent(email))
}
func (h *authHandler) VerifyMagicLink(w http.ResponseWriter, r *http.Request) {
tokenString := r.PathValue("token")
user, err := h.authService.VerifyMagicLink(tokenString)
if err != nil {
slog.Warn("magic link verification failed", "error", err, "token", tokenString)
ui.Render(w, r, pages.Auth("Invalid or expired magic link. Please try again."))
return
}
jwtToken, err := h.authService.GenerateJWT(user)
if err != nil {
slog.Error("failed to generate JWT", "error", err, "user_id", user.ID)
ui.Render(w, r, pages.Auth("An error occurred. Please try again."))
return
}
h.authService.SetJWTCookie(w, jwtToken, time.Now().Add(7*24*time.Hour))
// TODO: check for onboarding
slog.Info("user logged via magic link", "user_id", user.ID, "email", user.Email)
http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther)
}

View file

@ -10,13 +10,58 @@ import (
// TODO: implement clearing jwt token in auth service // TODO: implement clearing jwt token in auth service
// AuthMiddleware checks for JWT token and adds user + profile + subscription to context if valid // AuthMiddleware checks for JWT token and adds user + profile + subscription to context if valid
func AuthMiddleware(authService *service.AuthService, userService *service.UserService) func(http.Handler) http.Handler { func AuthMiddleware(authService *service.AuthService, userService *service.UserService, profileService *service.ProfileService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: get auth cookie and verify value // Get JWT from cookie
// TODO: fetch user information from database if cookie value is valid cookie, err := r.Cookie("auth_token")
// TODO: add user to context if valid if err != nil {
next.ServeHTTP(w, r) // No cookie, continue without auth
next.ServeHTTP(w, r)
return
}
// Verify token
claims, err := authService.VerifyJWT(cookie.Value)
if err != nil {
// Invalid token, clear cookie and continue
authService.ClearJWTCookie(w)
next.ServeHTTP(w, r)
return
}
// Get user ID from claims
userID, ok := claims["user_id"].(string)
if !ok {
authService.ClearJWTCookie(w)
next.ServeHTTP(w, r)
return
}
// Fetch user from database
user, err := userService.ByID(userID)
if err != nil {
authService.ClearJWTCookie(w)
next.ServeHTTP(w, r)
return
}
// Security: Remove password hash from context
user.PasswordHash = nil
profile, err := profileService.ByUserID(userID)
if err != nil {
// Profile not found - this shouldn't happen but handle gracefully
authService.ClearJWTCookie(w)
next.ServeHTTP(w, r)
return
}
// Add user + profile to context
ctx := ctxkeys.WithUser(r.Context(), user)
ctx = ctxkeys.WithProfile(ctx, profile)
next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
} }
@ -56,17 +101,17 @@ func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
// Check if user has completed onboarding // Check if user has completed onboarding
// Uses profile.Name as indicator (empty = incomplete onboarding) // Uses profile.Name as indicator (empty = incomplete onboarding)
profile := ctxkeys.Profile(r.Context()) // profile := ctxkeys.Profile(r.Context())
if profile.Name == "" && r.URL.Path != "/auth/onboarding" { // if profile.Name == "" && r.URL.Path != "/auth/onboarding" {
// User hasn't completed onboarding, redirect to onboarding // // User hasn't completed onboarding, redirect to onboarding
if r.Header.Get("HX-Request") == "true" { // if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/auth/onboarding") // w.Header().Set("HX-Redirect", "/auth/onboarding")
w.WriteHeader(http.StatusSeeOther) // w.WriteHeader(http.StatusSeeOther)
return // return
} // }
http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther) // http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther)
return // return
} // }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
} }

View file

@ -0,0 +1,117 @@
package middleware
import (
"net/http"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/service"
)
// TODO: implement clearing jwt token in auth service
// AuthMiddleware checks for JWT token and adds user + profile + subscription to context if valid
func AuthMiddleware(authService *service.AuthService, userService *service.UserService, profileService *service.ProfileService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get JWT from cookie
cookie, err := r.Cookie("auth_token")
if err != nil {
// No cookie, continue without auth
next.ServeHTTP(w, r)
return
}
// Verify token
claims, err := authService.VerifyJWT(cookie.Value)
if err != nil {
// Invalid token, clear cookie and continue
authService.ClearJWTCookie(w)
next.ServeHTTP(w, r)
return
}
// Get user ID from claims
userID, ok := claims["user_id"].(int64)
if !ok {
authService.ClearJWTCookie(w)
next.ServeHTTP(w, r)
return
}
// Fetch user from database
user, err := userService.ByID(userID)
if err != nil {
authService.ClearJWTCookie(w)
next.ServeHTTP(w, r)
return
}
// Security: Remove password hash from context
user.PasswordHash = nil
profile, err := profileService.ByUserID(userID)
if err != nil {
// Profile not found - this shouldn't happen but handle gracefully
authService.ClearJWTCookie(w)
next.ServeHTTP(w, r)
return
}
// Add user + profile to context
ctx := ctxkeys.WithUser(r.Context(), user)
ctx = ctxkeys.WithProfile(ctx, profile)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireGuest ensures request is not authenticated
func RequireGuest(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
if user != nil {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/app/dashboard")
w.WriteHeader(http.StatusSeeOther)
return
}
http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
}
}
// RequireAuth ensures the user is authenticated and has completed onboarding
func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
if user == nil {
// For HTMX requests, use HX-Redirect header to force full page redirect
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/auth")
w.WriteHeader(http.StatusSeeOther)
return
}
// For regular requests, use standard redirect
http.Redirect(w, r, "/auth", http.StatusSeeOther)
return
}
// Check if user has completed onboarding
// Uses profile.Name as indicator (empty = incomplete onboarding)
// profile := ctxkeys.Profile(r.Context())
// if profile.Name == "" && r.URL.Path != "/auth/onboarding" {
// // User hasn't completed onboarding, redirect to onboarding
// if r.Header.Get("HX-Request") == "true" {
// w.Header().Set("HX-Redirect", "/auth/onboarding")
// w.WriteHeader(http.StatusSeeOther)
// return
// }
// http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther)
// return
// }
next.ServeHTTP(w, r)
}
}

View file

@ -9,8 +9,8 @@ const (
) )
type File struct { type File struct {
ID uint64 `db:"id"` ID string `db:"id"`
UserID uint64 `db:"user_id"` // Who owns/created this file UserID string `db:"user_id"` // Who owns/created this file
OwnerType string `db:"owner_type"` // "user", "profile", etc. - the entity that owns the file OwnerType string `db:"owner_type"` // "user", "profile", etc. - the entity that owns the file
OwnerID string `db:"owner_id"` // Polymorphic FK OwnerID string `db:"owner_id"` // Polymorphic FK
Type string `db:"type"` Type string `db:"type"`

View file

@ -3,8 +3,8 @@ package model
import "time" import "time"
type Profile struct { type Profile struct {
ID uint64 `db:"id"` ID string `db:"id"`
UserID uint64 `db:"user_id"` UserID string `db:"user_id"`
Name string `db:"name"` Name string `db:"name"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`

View file

@ -5,8 +5,8 @@ import (
) )
type Token struct { type Token struct {
ID uint64 `db:"id"` ID string `db:"id"`
UserID uint64 `db:"user_id"` UserID string `db:"user_id"`
Type string `db:"type"` // "email_verify" or "password_reset" Type string `db:"type"` // "email_verify" or "password_reset"
Token string `db:"token"` Token string `db:"token"`
ExpiresAt time.Time `db:"expires_at"` ExpiresAt time.Time `db:"expires_at"`

View file

@ -3,7 +3,7 @@ package model
import "time" import "time"
type User struct { type User struct {
ID uint64 `db:"id"` ID string `db:"id"`
Email string `db:"email"` Email string `db:"email"`
// Allow null for passwordless users // Allow null for passwordless users
PasswordHash *string `db:"password_hash"` PasswordHash *string `db:"password_hash"`

View file

@ -0,0 +1,60 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrProfileNotFound = errors.New("profile not found")
)
type ProfileRepository interface {
Create(profile *model.Profile) (string, error)
ByUserID(userID string) (*model.Profile, error)
}
type profileRepository struct {
db *sqlx.DB
}
func NewProfileRepository(db *sqlx.DB) *profileRepository {
return &profileRepository{db: db}
}
func (r *profileRepository) Create(profile *model.Profile) (string, error) {
if profile.CreatedAt.IsZero() {
profile.CreatedAt = time.Now()
}
if profile.UpdatedAt.IsZero() {
profile.UpdatedAt = time.Now()
}
_, err := r.db.Exec(`
INSERT INTO profiles (id, user_id, name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
`, profile.ID, profile.UserID, profile.Name, profile.CreatedAt, profile.UpdatedAt)
if err != nil {
return "", err
}
return profile.ID, nil
}
func (r *profileRepository) ByUserID(userID string) (*model.Profile, error) {
var profile model.Profile
err := r.db.Get(&profile, `SELECT * FROM profiles WHERE user_id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrProfileNotFound
}
if err != nil {
return nil, err
}
return &profile, nil
}

View file

@ -0,0 +1,77 @@
package repository
import (
"database/sql"
"errors"
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrTokenNotFound = errors.New("token not found")
)
type TokenRepository interface {
Create(token *model.Token) (string, error)
DeleteByUserAndType(userID string, tokenType string) error
ConsumeToken(token string) (*model.Token, error)
}
type tokenRepository struct {
db *sqlx.DB
}
func NewTokenRepository(db *sqlx.DB) *tokenRepository {
return &tokenRepository{db: db}
}
func (r *tokenRepository) Create(token *model.Token) (string, error) {
if token.CreatedAt.IsZero() {
token.CreatedAt = time.Now()
}
query := `
INSERT INTO tokens (id, user_id, type, token, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
`
_, err := r.db.Exec(query, token.ID, token.UserID, token.Type, token.Token, token.ExpiresAt, token.CreatedAt)
if err != nil {
return "", fmt.Errorf("failed to create token: %w", err)
}
return token.ID, nil
}
func (r *tokenRepository) DeleteByUserAndType(userID string, tokenType string) error {
query := `DELETE FROM tokens WHERE user_id = $1 AND type = $2 AND used_at IS NULL`
_, err := r.db.Exec(query, userID, tokenType)
return err
}
func (r *tokenRepository) ConsumeToken(tokenString string) (*model.Token, error) {
var token model.Token
now := time.Now()
query := `
UPDATE tokens
SET used_at = $1
WHERE token = $2
AND used_at IS NULL
AND expires_at > $3
RETURNING *
`
err := r.db.Get(&token, query, now, tokenString, now)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrTokenNotFound
}
if err != nil {
return nil, err
}
return &token, nil
}

View file

@ -15,7 +15,7 @@ var (
) )
type UserRepository interface { type UserRepository interface {
Create(user *model.User) error Create(user *model.User) (string, error)
ByID(id string) (*model.User, error) ByID(id string) (*model.User, error)
ByEmail(email string) (*model.User, error) ByEmail(email string) (*model.User, error)
Update(user *model.User) error Update(user *model.User) error
@ -30,19 +30,19 @@ func NewUserRepository(db *sqlx.DB) UserRepository {
return &userRepository{db: db} return &userRepository{db: db}
} }
func (r *userRepository) Create(user *model.User) error { func (r *userRepository) Create(user *model.User) (string, error) {
query := `INSERT INTO users (id, email, password_hash, email_verified_at, created_at) VALUES ($1, $2, $3, $4, $5);` query := `INSERT INTO users (id, email, password_hash, email_verified_at, created_at) VALUES ($1, $2, $3, $4, $5);`
_, err := r.db.Exec(query, user.ID, user.Email, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt) _, err := r.db.Exec(query, user.ID, user.Email, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt)
if err != nil { if err != nil {
errStr := err.Error() errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") { if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
return ErrDuplicateEmail return "", ErrDuplicateEmail
} }
return err return "", err
} }
return nil return user.ID, nil
} }
func (r *userRepository) ByID(id string) (*model.User, error) { func (r *userRepository) ByID(id string) (*model.User, error) {
@ -58,15 +58,15 @@ func (r *userRepository) ByID(id string) (*model.User, error) {
} }
func (r *userRepository) ByEmail(email string) (*model.User, error) { func (r *userRepository) ByEmail(email string) (*model.User, error) {
user := &model.User{} var user model.User
query := `SELECT * FROM users WHERE email = $1;` query := `SELECT * FROM users WHERE email = $1;`
err := r.db.Get(user, query, email) err := r.db.Get(&user, query, email)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
return user, err return &user, err
} }
func (r *userRepository) Update(user *model.User) error { func (r *userRepository) Update(user *model.User) error {

View file

@ -11,7 +11,7 @@ import (
) )
func SetupRoutes(a *app.App) http.Handler { func SetupRoutes(a *app.App) http.Handler {
auth := handler.NewAuthHandler() auth := handler.NewAuthHandler(a.AuthService)
home := handler.NewHomeHandler() home := handler.NewHomeHandler()
dashboard := handler.NewDashboardHandler() dashboard := handler.NewDashboardHandler()
@ -26,9 +26,17 @@ func SetupRoutes(a *app.App) http.Handler {
mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub)))) mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(sub))))
// Auth pages // Auth pages
// authRateLimiter := middleware.RateLimitAuth()
mux.HandleFunc("GET /auth", middleware.RequireGuest(auth.AuthPage)) mux.HandleFunc("GET /auth", middleware.RequireGuest(auth.AuthPage))
mux.HandleFunc("GET /auth/password", middleware.RequireGuest(auth.PasswordPage)) mux.HandleFunc("GET /auth/password", middleware.RequireGuest(auth.PasswordPage))
// Token Verifications
mux.HandleFunc("GET /auth/magic-link/{token}", auth.VerifyMagicLink)
// Auth Actions
mux.HandleFunc("POST /auth/magic-link", middleware.RequireGuest(auth.SendMagicLink))
// ==================================================================================== // ====================================================================================
// PRIVATE ROUTES // PRIVATE ROUTES
// ==================================================================================== // ====================================================================================
@ -44,7 +52,7 @@ func SetupRoutes(a *app.App) http.Handler {
middleware.Config(a.Cfg), middleware.Config(a.Cfg),
middleware.RequestLogging, middleware.RequestLogging,
middleware.CSRFProtection, middleware.CSRFProtection,
middleware.AuthMiddleware(a.AuthService, a.UserService), middleware.AuthMiddleware(a.AuthService, a.UserService, a.ProfileService),
middleware.WithURLPath, middleware.WithURLPath,
) )

View file

@ -1,14 +1,22 @@
package service package service
import ( import (
"crypto/rand"
"encoding/hex"
"errors" "errors"
"fmt"
"log/slog"
"net/http"
"strings" "strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/exception" "git.juancwu.dev/juancwu/budgit/internal/exception"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/validation"
"github.com/alexedwards/argon2id" "github.com/alexedwards/argon2id"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
) )
var ( var (
@ -24,12 +32,35 @@ var (
) )
type AuthService struct { type AuthService struct {
userRepository repository.UserRepository emailService *EmailService
userRepository repository.UserRepository
profileRepository repository.ProfileRepository
tokenRepository repository.TokenRepository
jwtSecret string
jwtExpiry time.Duration
tokenMagicLinkExpiry time.Duration
isProduction bool
} }
func NewAuthService(userRepository repository.UserRepository) *AuthService { func NewAuthService(
emailService *EmailService,
userRepository repository.UserRepository,
profileRepository repository.ProfileRepository,
tokenRepository repository.TokenRepository,
jwtSecret string,
jwtExpiry time.Duration,
tokenMagicLinkExpiry time.Duration,
isProduction bool,
) *AuthService {
return &AuthService{ return &AuthService{
userRepository: userRepository, emailService: emailService,
userRepository: userRepository,
profileRepository: profileRepository,
tokenRepository: tokenRepository,
jwtSecret: jwtSecret,
jwtExpiry: jwtExpiry,
tokenMagicLinkExpiry: tokenMagicLinkExpiry,
isProduction: isProduction,
} }
} }
@ -75,6 +106,188 @@ func (s *AuthService) ComparePassword(password, hash string) error {
return nil return nil
} }
func (s *AuthService) VerifyJWT(value string) (jwt.MapClaims, error) { func (s *AuthService) GenerateJWT(user *model.User) (string, error) {
return nil, nil claims := jwt.MapClaims{
"user_id": user.ID,
"email": user.Email,
"exp": time.Now().Add(s.jwtExpiry).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(s.jwtSecret))
if err != nil {
return "", err
}
return tokenString, nil
}
func (s *AuthService) VerifyJWT(tokenString string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.jwtSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
func (s *AuthService) SetJWTCookie(w http.ResponseWriter, token string, expiry time.Time) {
http.SetCookie(w, &http.Cookie{
Name: "auth_token",
Value: token,
Expires: expiry,
Path: "/",
HttpOnly: true,
Secure: s.isProduction,
SameSite: http.SameSiteLaxMode,
})
}
func (s *AuthService) ClearJWTCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "auth_token",
Value: "",
Expires: time.Unix(0, 0),
Path: "/",
HttpOnly: true,
Secure: s.isProduction,
SameSite: http.SameSiteLaxMode,
})
}
func (s *AuthService) GenerateToken() (string, error) {
bytes := make([]byte, 32)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func (s *AuthService) SendMagicLink(email string) error {
email = strings.TrimSpace(strings.ToLower(email))
err := validation.ValidateEmail(email)
if err != nil {
return ErrInvalidEmail
}
user, err := s.userRepository.ByEmail(email)
if err != nil {
// User doesn't exists - create a new passwordless account
if errors.Is(err, repository.ErrUserNotFound) {
now := time.Now()
user = &model.User{
ID: uuid.NewString(),
Email: email,
CreatedAt: now,
}
_, err := s.userRepository.Create(user)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
slog.Info("new user created with id", "id", user.ID)
profile := &model.Profile{
ID: uuid.NewString(),
UserID: user.ID,
Name: "",
CreatedAt: now,
UpdatedAt: now,
}
_, err = s.profileRepository.Create(profile)
if err != nil {
return fmt.Errorf("failed to create profile: %w", err)
}
slog.Info("new passwordless user created", "email", email, "user_id", user.ID)
} else {
// user look up unexpected error
return fmt.Errorf("failed to look up user: %w", err)
}
}
err = s.tokenRepository.DeleteByUserAndType(user.ID, model.TokenTypeMagicLink)
if err != nil {
slog.Warn("failed to delete old magic link tokens", "error", err, "user_id", user.ID)
}
magicToken, err := s.GenerateToken()
if err != nil {
return fmt.Errorf("failed to generate token: %w", err)
}
token := &model.Token{
ID: uuid.NewString(),
UserID: user.ID,
Type: model.TokenTypeMagicLink,
Token: magicToken,
ExpiresAt: time.Now().Add(s.tokenMagicLinkExpiry),
}
_, err = s.tokenRepository.Create(token)
if err != nil {
return fmt.Errorf("failed to create token: %w", err)
}
profile, err := s.profileRepository.ByUserID(user.ID)
name := ""
if err == nil && profile != nil {
name = profile.Name
}
err = s.emailService.SendMagicLinkEmail(user.Email, magicToken, name)
if err != nil {
slog.Error("failed to send magic link email", "error", err, "email", user.Email)
return fmt.Errorf("failed to send email: %w", err)
}
slog.Info("magic link sent", "email", user.Email)
return nil
}
func (s *AuthService) VerifyMagicLink(tokenString string) (*model.User, error) {
token, err := s.tokenRepository.ConsumeToken(tokenString)
if err != nil {
return nil, fmt.Errorf("invalid or expired magic link")
}
if token.Type != model.TokenTypeMagicLink {
return nil, fmt.Errorf("invalid token type")
}
user, err := s.userRepository.ByID(token.UserID)
if errors.Is(err, repository.ErrUserNotFound) {
return nil, err
}
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
if user.EmailVerifiedAt == nil {
now := time.Now()
user.EmailVerifiedAt = &now
err = s.userRepository.Update(user)
if err != nil {
slog.Warn("failed to set email verification time", "error", err, "user_id", user.ID)
}
}
slog.Info("user authenticated via magic link", "user_id", user.ID, "email", user.Email)
return user, nil
} }

View file

@ -13,15 +13,14 @@ import (
) )
type EmailParams struct { type EmailParams struct {
From string From string
EnvelopeFrom string To []string
To []string Bcc []string
Bcc []string Cc []string
Cc []string ReplyTo string
ReplyTo string Subject string
Subject string Text string
Text string Html string
Html string
} }
type EmailClient struct { type EmailClient struct {
@ -47,12 +46,20 @@ func NewEmailClient(smtpHost string, smtpPort int, imapHost string, imapPort int
func (nc *EmailClient) SendWithContext(ctx context.Context, params *EmailParams) (string, error) { func (nc *EmailClient) SendWithContext(ctx context.Context, params *EmailParams) (string, error) {
m := mail.NewMsg() m := mail.NewMsg()
m.From(params.From) m.From(params.From)
m.EnvelopeFrom(params.EnvelopeFrom)
m.To(params.To...) m.To(params.To...)
m.Subject(params.Subject) m.Subject(params.Subject)
m.SetBodyString(mail.TypeTextPlain, params.Text)
m.SetBodyString(mail.TypeTextHTML, params.Html) if params.Html != "" {
m.ReplyTo(params.ReplyTo) m.SetBodyString(mail.TypeTextHTML, params.Html)
m.AddAlternativeString(mail.TypeTextPlain, params.Text)
} else {
m.SetBodyString(mail.TypeTextPlain, params.Text)
}
if params.ReplyTo != "" {
m.ReplyTo(params.ReplyTo)
}
m.SetDate() m.SetDate()
m.SetMessageID() m.SetMessageID()
@ -126,26 +133,20 @@ func (nc *EmailClient) connectToIMAP() (*client.Client, error) {
} }
type EmailService struct { type EmailService struct {
client *EmailClient client *EmailClient
fromEmail string fromEmail string
fromEnvelope string isProd bool
supportEmail string appURL string
supportEnvelope string appName string
isDev bool
appURL string
appName string
} }
func NewEmailService(client *EmailClient, fromEmail, fromEnvelope, supportEmail, supportEnvelope, appURL, appName string, isDev bool) *EmailService { func NewEmailService(client *EmailClient, fromEmail, appURL, appName string, isProd bool) *EmailService {
return &EmailService{ return &EmailService{
client: client, client: client,
fromEmail: fromEmail, fromEmail: fromEmail,
fromEnvelope: fromEnvelope, isProd: isProd,
supportEmail: supportEmail, appURL: appURL,
supportEnvelope: supportEnvelope, appName: appName,
isDev: isDev,
appURL: appURL,
appName: appName,
} }
} }
@ -153,10 +154,10 @@ func (s *EmailService) SendMagicLinkEmail(email, token, name string) error {
magicURL := fmt.Sprintf("%s/auth/magic-link/%s", s.appURL, token) magicURL := fmt.Sprintf("%s/auth/magic-link/%s", s.appURL, token)
subject, body := magicLinkEmailTemplate(magicURL, s.appName) subject, body := magicLinkEmailTemplate(magicURL, s.appName)
if s.isDev { // if !s.isProd {
slog.Info("email sent (dev mode)", "type", "magic_link", "to", email, "subject", subject, "url", magicURL) // slog.Info("email sent (dev mode)", "type", "magic_link", "to", email, "subject", subject, "url", magicURL)
return nil // return nil
} // }
params := &EmailParams{ params := &EmailParams{
From: s.fromEmail, From: s.fromEmail,

View file

@ -0,0 +1,20 @@
package service
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
)
type ProfileService struct {
profileRepository repository.ProfileRepository
}
func NewProfileService(profileRepository repository.ProfileRepository) *ProfileService {
return &ProfileService{
profileRepository: profileRepository,
}
}
func (s *ProfileService) ByUserID(userID string) (*model.Profile, error) {
return s.profileRepository.ByUserID(userID)
}

View file

@ -0,0 +1,67 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
)
templ MagicLinkSent(email string) {
@layouts.Auth(layouts.SEOProps{
Title: "Check Your Email",
Description: "Magic link sent to your email",
Path: ctxkeys.URLPath(ctx),
}) {
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<div class="mb-8">
<div class="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
@icon.Mail()
</div>
</div>
<h2 class="text-3xl font-bold">Check your email</h2>
<p class="text-muted-foreground mt-2">We've sent a magic link to</p>
<p class="font-medium mt-1">{ email }</p>
</div>
<div class="space-y-6">
<div class="rounded-lg bg-muted/50 p-4 text-sm text-muted-foreground space-y-2">
<p>Click the link in your email to sign in instantly.</p>
<p>The link will expire in 10 minutes and can only be used once.</p>
</div>
<div class="space-y-3">
<form
hx-post="/auth/magic-link?resend=true"
hx-swap="none"
hx-disabled-elt="find button"
>
@csrf.Token()
<input type="hidden" name="email" value={ email }/>
@button.Button(button.Props{
Variant: button.VariantOutline,
FullWidth: true,
Type: button.TypeSubmit,
}) {
Resend magic link
}
</form>
{{ cfg := ctxkeys.Config(ctx) }}
<p class="text-xs text-center text-muted-foreground">
Didn't receive it? Check your spam folder or
<a href={ templ.SafeURL("mailto:" + cfg.SupportEmail) } class="text-primary hover:underline">
contact support
</a>
</p>
<p class="text-center text-sm">
<a href="/auth" class="text-primary hover:underline">
Back to login
</a>
</p>
</div>
</div>
</div>
</div>
}
}