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

@ -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 {
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{
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
}
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

@ -13,15 +13,14 @@ import (
)
type EmailParams struct {
From string
EnvelopeFrom string
To []string
Bcc []string
Cc []string
ReplyTo string
Subject string
Text string
Html string
From string
To []string
Bcc []string
Cc []string
ReplyTo string
Subject string
Text string
Html string
}
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) {
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)
m.SetBodyString(mail.TypeTextHTML, params.Html)
m.ReplyTo(params.ReplyTo)
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()
@ -126,26 +133,20 @@ func (nc *EmailClient) connectToIMAP() (*client.Client, error) {
}
type EmailService struct {
client *EmailClient
fromEmail string
fromEnvelope string
supportEmail string
supportEnvelope string
isDev bool
appURL string
appName string
client *EmailClient
fromEmail string
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,
appURL: appURL,
appName: appName,
client: client,
fromEmail: fromEmail,
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)
}