login via magic link
This commit is contained in:
parent
9fe6a6beb1
commit
94a05b0433
22 changed files with 815 additions and 122 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue