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_PASSWORD=
MAILER_EMAIL_FROM=
MAILER_ENVELOPE_FROM=
MAILER_SUPPORT_EMAIL=
MAILER_SUPPORT_ENVELOPE_FROM=
SUPPORT_EMAIL=

View file

@ -16,6 +16,7 @@ type App struct {
UserService *service.UserService
AuthService *service.AuthService
EmailService *service.EmailService
ProfileService *service.ProfileService
}
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)
userRepository := repository.NewUserRepository(database)
profileRepository := repository.NewProfileRepository(database)
tokenRepository := repository.NewTokenRepository(database)
userService := service.NewUserService(userRepository)
authService := service.NewAuthService(userRepository)
emailService := service.NewEmailService(emailClient, cfg.MailerEmailFrom, cfg.MailerEnvelopeFrom, cfg.MailerSupportFrom, cfg.MailerSupportEnvelopeFrom, cfg.AppURL, cfg.AppName, cfg.AppEnv == "development")
emailService := service.NewEmailService(
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{
Cfg: cfg,
@ -43,6 +62,7 @@ func New(cfg *config.Config) (*App, error) {
UserService: userService,
AuthService: authService,
EmailService: emailService,
ProfileService: profileService,
}, nil
}

View file

@ -22,6 +22,7 @@ type Config struct {
JWTSecret string
JWTExpiry time.Duration
TokenMagicLinkExpiry time.Duration
MailerSMTPHost string
MailerSMTPPort int
@ -30,9 +31,8 @@ type Config struct {
MailerUsername string
MailerPassword string
MailerEmailFrom string
MailerEnvelopeFrom string
MailerSupportFrom string
MailerSupportEnvelopeFrom string
SupportEmail string
}
func Load() *Config {
@ -54,6 +54,7 @@ func Load() *Config {
JWTSecret: envRequired("JWT_SECRET"),
JWTExpiry: envDuration("JWT_EXPIRY", 168*time.Hour), // 7 days default
TokenMagicLinkExpiry: envDuration("TOKEN_MAGIC_LINK_EXPIRY", 10*time.Minute),
MailerSMTPHost: envString("MAILER_SMTP_HOST", ""),
MailerSMTPPort: envInt("MAILER_SMTP_PORT", 587),
@ -62,9 +63,8 @@ func Load() *Config {
MailerUsername: envString("MAILER_USERNAME", ""),
MailerPassword: envString("MAILER_PASSWORD", ""),
MailerEmailFrom: envString("MAILER_EMAIL_FROM", ""),
MailerEnvelopeFrom: envString("MAILER_ENVELOPE_FROM", ""),
MailerSupportFrom: envString("MAILER_SUPPORT_EMAIL_FROM", ""),
MailerSupportEnvelopeFrom: envString("MAILER_SUPPORT_ENVELOPE_FROM", ""),
SupportEmail: envString("SUPPORT_EMAIL", ""),
}
return cfg
@ -86,7 +86,7 @@ func (c *Config) Sanitized() *Config {
AppTagline: c.AppTagline,
MailerEmailFrom: c.MailerEmailFrom,
MailerEnvelopeFrom: c.MailerEnvelopeFrom,
SupportEmail: c.SupportEmail,
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,17 +1,24 @@
package handler
import (
"log/slog"
"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/components/toast"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
"git.juancwu.dev/juancwu/budgit/internal/validation"
)
type authHandler struct {
authService *service.AuthService
}
func NewAuthHandler() *authHandler {
return &authHandler{}
func NewAuthHandler(authService *service.AuthService) *authHandler {
return &authHandler{authService: authService}
}
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) {
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
// 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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: get auth cookie and verify value
// TODO: fetch user information from database if cookie value is valid
// TODO: add user to context if valid
// 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"].(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
// 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
}
// 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

@ -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 {
ID uint64 `db:"id"`
UserID uint64 `db:"user_id"` // Who owns/created this file
ID string `db:"id"`
UserID string `db:"user_id"` // Who owns/created this file
OwnerType string `db:"owner_type"` // "user", "profile", etc. - the entity that owns the file
OwnerID string `db:"owner_id"` // Polymorphic FK
Type string `db:"type"`

View file

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

View file

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

View file

@ -3,7 +3,7 @@ package model
import "time"
type User struct {
ID uint64 `db:"id"`
ID string `db:"id"`
Email string `db:"email"`
// Allow null for passwordless users
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 {
Create(user *model.User) error
Create(user *model.User) (string, error)
ByID(id string) (*model.User, error)
ByEmail(email string) (*model.User, error)
Update(user *model.User) error
@ -30,19 +30,19 @@ func NewUserRepository(db *sqlx.DB) UserRepository {
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);`
_, err := r.db.Exec(query, user.ID, user.Email, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt)
if err != nil {
errStr := err.Error()
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) {
@ -58,15 +58,15 @@ func (r *userRepository) ByID(id 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;`
err := r.db.Get(user, query, email)
err := r.db.Get(&user, query, email)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
return user, err
return &user, err
}
func (r *userRepository) Update(user *model.User) error {

View file

@ -11,7 +11,7 @@ import (
)
func SetupRoutes(a *app.App) http.Handler {
auth := handler.NewAuthHandler()
auth := handler.NewAuthHandler(a.AuthService)
home := handler.NewHomeHandler()
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))))
// Auth pages
// authRateLimiter := middleware.RateLimitAuth()
mux.HandleFunc("GET /auth", middleware.RequireGuest(auth.AuthPage))
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
// ====================================================================================
@ -44,7 +52,7 @@ func SetupRoutes(a *app.App) http.Handler {
middleware.Config(a.Cfg),
middleware.RequestLogging,
middleware.CSRFProtection,
middleware.AuthMiddleware(a.AuthService, a.UserService),
middleware.AuthMiddleware(a.AuthService, a.UserService, a.ProfileService),
middleware.WithURLPath,
)

View file

@ -1,14 +1,22 @@
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/exception"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/validation"
"github.com/alexedwards/argon2id"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
var (
@ -24,12 +32,35 @@ var (
)
type AuthService struct {
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{
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
}
func (s *AuthService) VerifyJWT(value string) (jwt.MapClaims, error) {
return nil, nil
func (s *AuthService) GenerateJWT(user *model.User) (string, error) {
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 {
From string
EnvelopeFrom string
To []string
Bcc []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) {
m := mail.NewMsg()
m.From(params.From)
m.EnvelopeFrom(params.EnvelopeFrom)
m.To(params.To...)
m.Subject(params.Subject)
m.SetBodyString(mail.TypeTextPlain, params.Text)
if 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.SetDate()
m.SetMessageID()
@ -128,22 +135,16 @@ func (nc *EmailClient) connectToIMAP() (*client.Client, error) {
type EmailService struct {
client *EmailClient
fromEmail string
fromEnvelope string
supportEmail string
supportEnvelope string
isDev bool
isProd 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{
client: client,
fromEmail: fromEmail,
fromEnvelope: fromEnvelope,
supportEmail: supportEmail,
supportEnvelope: supportEnvelope,
isDev: isDev,
isProd: isProd,
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)
subject, body := magicLinkEmailTemplate(magicURL, s.appName)
if s.isDev {
slog.Info("email sent (dev mode)", "type", "magic_link", "to", email, "subject", subject, "url", magicURL)
return nil
}
// if !s.isProd {
// slog.Info("email sent (dev mode)", "type", "magic_link", "to", email, "subject", subject, "url", magicURL)
// return nil
// }
params := &EmailParams{
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>
}
}