diff --git a/cmd/create-user/main.go b/cmd/create-user/main.go index 79773be..53d6165 100644 --- a/cmd/create-user/main.go +++ b/cmd/create-user/main.go @@ -10,8 +10,8 @@ import ( _ "github.com/mattn/go-sqlite3" - "malago/internal/auth" "malago/internal/database" + "malago/internal/features/auth" ) func main() { diff --git a/cmd/server/main.go b/cmd/server/main.go index ba33a9a..72a7096 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,13 +8,14 @@ import ( _ "github.com/mattn/go-sqlite3" - "malago/internal/auth" "malago/internal/database" + "malago/internal/features/auth" "malago/internal/jikan" "malago/internal/server" ) func main() { + dbFile := os.Getenv("DATABASE_FILE") if dbFile == "" { dbFile = "malago.db" diff --git a/internal/features/auth/auth.go b/internal/features/auth/auth.go new file mode 100644 index 0000000..17baa95 --- /dev/null +++ b/internal/features/auth/auth.go @@ -0,0 +1,141 @@ +package auth + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "malago/internal/database" +) + +var ( + ErrInvalidCredentials = errors.New("invalid username or password") + ErrUserExists = errors.New("username already exists") + ErrNotAuthenticated = errors.New("not authenticated") +) + +type Service struct { + db database.Querier +} + +func NewService(db database.Querier) *Service { + return &Service{db: db} +} + +// generateSessionToken generates a secure random 32-byte token. +func generateSessionToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +func (s *Service) RegisterUser(ctx context.Context, username, password string) (*database.User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + id := uuid.New().String() + user, err := s.db.CreateUser(ctx, database.CreateUserParams{ + ID: id, + Username: username, + PasswordHash: string(hash), + }) + if err != nil { + // Assuming unique constraint failure for username + return nil, ErrUserExists + } + + return &user, nil +} + +func (s *Service) Login(ctx context.Context, username, password string) (*database.Session, error) { + user, err := s.db.GetUserByUsername(ctx, username) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrInvalidCredentials + } + return nil, fmt.Errorf("failed to lookup user: %w", err) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, ErrInvalidCredentials + } + + token, err := generateSessionToken() + if err != nil { + return nil, fmt.Errorf("failed to generate session token: %w", err) + } + + expiresAt := time.Now().Add(30 * 24 * time.Hour) // 30 days + session, err := s.db.CreateSession(ctx, database.CreateSessionParams{ + ID: token, + UserID: user.ID, + ExpiresAt: expiresAt, + }) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + + return &session, nil +} + +func (s *Service) Logout(ctx context.Context, sessionID string) error { + return s.db.DeleteSession(ctx, sessionID) +} + +func (s *Service) ValidateSession(ctx context.Context, sessionID string) (*database.User, error) { + session, err := s.db.GetSession(ctx, sessionID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotAuthenticated + } + return nil, fmt.Errorf("failed to get session: %w", err) + } + + if time.Now().After(session.ExpiresAt) { + _ = s.db.DeleteSession(ctx, sessionID) + return nil, ErrNotAuthenticated + } + + user, err := s.db.GetUser(ctx, session.UserID) + if err != nil { + return nil, fmt.Errorf("failed to get user for session: %w", err) + } + + return &user, nil +} + +func SetSessionCookie(w http.ResponseWriter, sessionID string, expiresAt time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: "session_id", + Value: sessionID, + Expires: expiresAt, + HttpOnly: true, + Secure: false, // False for local development without TLS + SameSite: http.SameSiteLaxMode, + Path: "/", + }) +} + +func ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "session_id", + Value: "", + Expires: time.Unix(0, 0), + HttpOnly: true, + Secure: false, // False for local development without TLS + SameSite: http.SameSiteLaxMode, + Path: "/", + }) +} diff --git a/internal/features/auth/handler.go b/internal/features/auth/handler.go new file mode 100644 index 0000000..c2fc57a --- /dev/null +++ b/internal/features/auth/handler.go @@ -0,0 +1,55 @@ +package auth + +import ( + "net/http" + + "malago/internal/templates" +) + +type Handler struct { + authService *Service +} + +func NewHandler(authService *Service) *Handler { + return &Handler{authService: authService} +} + +// Render the login/register pages here (assuming you have these templates) + +func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + session, err := h.authService.Login(r.Context(), username, password) + if err != nil { + // Just handle generically for now, perhaps via HTMX toast + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + + SetSessionCookie(w, session.ID, session.ExpiresAt) + + // HTMX-friendly redirect to root or previous page + w.Header().Set("HX-Redirect", "/") + http.Redirect(w, r, "/", http.StatusFound) +} + +func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session_id") + if err == nil { + _ = h.authService.Logout(r.Context(), cookie.Value) + } + + ClearSessionCookie(w) + w.Header().Set("HX-Redirect", "/") + http.Redirect(w, r, "/", http.StatusFound) +} + +func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) { + templates.Login().Render(r.Context(), w) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 6257ed4..f57d097 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -5,8 +5,8 @@ import ( "net/http" "strings" - "malago/internal/auth" "malago/internal/database" + "malago/internal/features/auth" ) type contextKey string diff --git a/internal/server/routes.go b/internal/server/routes.go index d5c84db..cb81296 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -3,8 +3,8 @@ package server import ( "net/http" - "malago/internal/auth" "malago/internal/database" + "malago/internal/features/auth" "malago/internal/handlers" "malago/internal/jikan" "malago/internal/middleware" @@ -19,7 +19,7 @@ type Config struct { func NewRouter(cfg Config) http.Handler { mux := http.NewServeMux() - authHandler := handlers.NewAuthHandler(cfg.AuthService) + authHandler := auth.NewHandler(cfg.AuthService) watchlistHandler := handlers.NewWatchlistHandler(cfg.DB) animeHandler := handlers.NewAnimeHandler(cfg.JikanClient, cfg.DB)