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

@ -16,6 +16,7 @@ type App struct {
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,10 +33,28 @@ 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,
@ -43,6 +62,7 @@ func New(cfg *config.Config) (*App, error) {
UserService: userService, UserService: userService,
AuthService: authService, AuthService: authService,
EmailService: emailService, EmailService: emailService,
ProfileService: profileService,
}, nil }, nil
} }

View file

@ -22,6 +22,7 @@ type Config struct {
JWTSecret string JWTSecret string
JWTExpiry time.Duration JWTExpiry time.Duration
TokenMagicLinkExpiry time.Duration
MailerSMTPHost string MailerSMTPHost string
MailerSMTPPort int MailerSMTPPort int
@ -30,9 +31,8 @@ type Config struct {
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 {
@ -54,6 +54,7 @@ func Load() *Config {
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),
@ -62,9 +63,8 @@ func Load() *Config {
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
@ -86,7 +86,7 @@ func (c *Config) Sanitized() *Config {
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 {
// No cookie, continue without auth
next.ServeHTTP(w, r) 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 {
emailService *EmailService
userRepository repository.UserRepository 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{
emailService: emailService,
userRepository: userRepository, 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

@ -14,7 +14,6 @@ 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
@ -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)
if params.Html != "" {
m.SetBodyString(mail.TypeTextHTML, params.Html) 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.ReplyTo(params.ReplyTo)
}
m.SetDate() m.SetDate()
m.SetMessageID() m.SetMessageID()
@ -128,22 +135,16 @@ 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
supportEnvelope string
isDev bool
appURL string appURL string
appName 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,
supportEnvelope: supportEnvelope,
isDev: isDev,
appURL: appURL, appURL: appURL,
appName: appName, 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>
}
}