login via magic link
This commit is contained in:
parent
9fe6a6beb1
commit
94a05b0433
22 changed files with 815 additions and 122 deletions
|
|
@ -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=
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
117
internal/middleware/auth.go.bak
Normal file
117
internal/middleware/auth.go.bak
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
60
internal/repository/profile.go
Normal file
60
internal/repository/profile.go
Normal 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
|
||||||
|
}
|
||||||
77
internal/repository/token.go
Normal file
77
internal/repository/token.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
20
internal/service/profile.go
Normal file
20
internal/service/profile.go
Normal 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)
|
||||||
|
}
|
||||||
67
internal/ui/pages/auth_magic_link_sent.templ
Normal file
67
internal/ui/pages/auth_magic_link_sent.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue