From d0311ccd241f235203051513877cb588be4c7384 Mon Sep 17 00:00:00 2001 From: Ruidy Date: Sat, 20 Sep 2025 12:49:32 +0200 Subject: [PATCH] feat: add signup flow --- AUTH_PLAN.md | 1 + cmd/server/main.go | 13 +++++- internal/auth/service.go | 54 +++++++++++++++++++++++++ internal/auth/store.go | 1 - internal/server/handler_auth.go | 66 +++++++++++++++++++++++++++++-- internal/server/handler_public.go | 4 ++ internal/server/routes.go | 29 +++++++++++++- internal/server/server.go | 20 +--------- internal/server/views.go | 5 +++ web/templates/index.html | 4 ++ web/templates/signup.html | 49 +++++++++++++++++++++++ 11 files changed, 219 insertions(+), 27 deletions(-) create mode 100644 web/templates/signup.html diff --git a/AUTH_PLAN.md b/AUTH_PLAN.md index c6fbef3..43dee59 100644 --- a/AUTH_PLAN.md +++ b/AUTH_PLAN.md @@ -36,3 +36,4 @@ - Cover hashing and CSRF helpers with table-driven unit tests. - Use `net/http/httptest` to verify happy-path login, signup, logout, invalid credential handling, CSRF failures, and session persistence. - Run `go test -cover ./...` to ensure the new logic maintains regression coverage. +- Flesh out dedicated service tests for lookup flows and extend dashboard coverage once integration scaffolding is available. diff --git a/cmd/server/main.go b/cmd/server/main.go index 9426911..bdf08c1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "log" "net/http" @@ -8,13 +9,21 @@ import ( ) func main() { + if err := run(); err != nil { + log.Fatalf("run: %v", err) + } +} + +func run() error { srv, err := server.New() if err != nil { - log.Fatalf("initialise server: %v", err) + return fmt.Errorf("initialise server: %v", err) } log.Println("Starting server on http://localhost:8000") if err := http.ListenAndServe(":8000", srv.Router()); err != nil { - log.Fatalf("listen: %v", err) + return fmt.Errorf("listen: %v", err) } + + return nil } diff --git a/internal/auth/service.go b/internal/auth/service.go index 00b3ef0..cff9e22 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -2,7 +2,11 @@ package auth import ( "context" + "crypto/rand" + "encoding/base64" "errors" + "fmt" + "time" ) var ( @@ -10,8 +14,12 @@ var ( ErrInvalidInput = errors.New("auth: invalid input") // ErrInvalidCredentials indicates the credentials do not match any account. ErrInvalidCredentials = errors.New("auth: invalid credentials") + // ErrEmailExists indicates an account already uses the provided email address. + ErrEmailExists = errors.New("auth: email already registered") ) +const userIDByteLength = 16 + // Service exposes authentication business operations to HTTP handlers. type Service struct { store UserStore @@ -51,3 +59,49 @@ func (s *Service) LookupByEmail(ctx context.Context, email UserEmail) (*User, er return s.store.FindByEmail(ctx, email) } + +// Register provisions a new user account for the provided credentials. +func (s *Service) Register(ctx context.Context, email UserEmail, password string) (*User, error) { + if email.IsZero() || password == "" { + return nil, ErrInvalidInput + } + + if existing, err := s.store.FindByEmail(ctx, email); err == nil && existing != nil { + return nil, ErrEmailExists + } else if err != nil && !errors.Is(err, ErrUserNotFound) { + return nil, err + } + + id, err := generateUserID() + if err != nil { + return nil, fmt.Errorf("generate user id: %w", err) + } + + salt, hash, err := HashPassword(password) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + user := User{ + ID: id, + Email: email, + PasswordSalt: salt, + PasswordHash: hash, + CreatedAt: time.Now().UTC(), + } + + if err := s.store.Create(ctx, user); err != nil { + return nil, err + } + + return &user, nil +} + +// TODO: could be UUID. return a dedicated type +func generateUserID() (string, error) { + buf := make([]byte, userIDByteLength) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/internal/auth/store.go b/internal/auth/store.go index 09220b1..17209bd 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -48,7 +48,6 @@ func (s *MemoryStore) FindByEmail(_ context.Context, email UserEmail) (*User, er } // Create inserts or replaces the stored user by email. - func (s *MemoryStore) Create(_ context.Context, user User) error { if user.Email.IsZero() { return ErrEmailRequired diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index 4be897f..2740000 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -8,6 +8,25 @@ import ( "github.com/rjnemo/auth/internal/auth" ) +const ( + credentialRequiredMsg = "Email and password are required." + invalidCredentialsMsg = "Invalid credentials." + duplicateEmailMsg = "An account with that email already exists." +) + +func (s *Server) signupHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + state := sessionFromContext(r.Context()) + + if state.Authenticated { + http.Redirect(w, r, "/in", http.StatusSeeOther) + return + } + + s.render(w, "signup.html", newSignupData(state.Email, "", state.CSRFToken)) + } +} + func (s *Server) loginHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { state := sessionFromContext(r.Context()) @@ -23,7 +42,7 @@ func (s *Server) loginHandler() http.HandlerFunc { email, err := auth.NewUserEmail(emailInput) if err != nil { w.WriteHeader(http.StatusBadRequest) - s.render(w, "index.html", newIndexData("", "Email and password are required.", state.CSRFToken)) + s.render(w, "index.html", newIndexData("", credentialRequiredMsg, state.CSRFToken)) return } @@ -39,7 +58,7 @@ func (s *Server) loginHandler() http.HandlerFunc { case errors.Is(err, auth.ErrInvalidInput): w.WriteHeader(http.StatusBadRequest) - s.render(w, "index.html", newIndexData(email.String(), "Email and password are required.", state.CSRFToken)) + s.render(w, "index.html", newIndexData(email.String(), credentialRequiredMsg, state.CSRFToken)) case errors.Is(err, auth.ErrInvalidCredentials): s.renderLoginFailure(w, email, state.CSRFToken) default: @@ -58,5 +77,46 @@ func (s *Server) logoutHandler() http.HandlerFunc { func (s *Server) renderLoginFailure(w http.ResponseWriter, email auth.UserEmail, token string) { w.WriteHeader(http.StatusUnauthorized) - s.render(w, "index.html", newIndexData(email.String(), "Invalid credentials.", token)) + s.render(w, "index.html", newIndexData(email.String(), invalidCredentialsMsg, token)) +} + +func (s *Server) registerHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + state := sessionFromContext(r.Context()) + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form submission", http.StatusBadRequest) + return + } + + emailValue := r.FormValue("email") + password := r.FormValue("password") + + email, err := auth.NewUserEmail(emailValue) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + s.render(w, "signup.html", newSignupData("", credentialRequiredMsg, state.CSRFToken)) + return + } + + account, err := s.authService.Register(r.Context(), email, password) + switch { + case err == nil: + state.Authenticated = true + state.Email = account.Email.String() + if err := s.sessions.Save(w, state); err != nil { + log.Printf("session: save failed: %v", err) + } + http.Redirect(w, r, "/in", http.StatusSeeOther) + case errors.Is(err, auth.ErrInvalidInput): + w.WriteHeader(http.StatusBadRequest) + s.render(w, "signup.html", newSignupData(email.String(), credentialRequiredMsg, state.CSRFToken)) + case errors.Is(err, auth.ErrEmailExists): + w.WriteHeader(http.StatusConflict) + s.render(w, "signup.html", newSignupData(email.String(), duplicateEmailMsg, state.CSRFToken)) + default: + log.Printf("auth: register failed: %v", err) + http.Error(w, "unexpected error", http.StatusInternalServerError) + } + } } diff --git a/internal/server/handler_public.go b/internal/server/handler_public.go index 0c0fe51..08359c9 100644 --- a/internal/server/handler_public.go +++ b/internal/server/handler_public.go @@ -5,6 +5,10 @@ import "net/http" func (s *Server) indexHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { state := sessionFromContext(r.Context()) + if state.Authenticated { + http.Redirect(w, r, "/in", http.StatusSeeOther) + return + } s.render(w, "index.html", newIndexData(state.Email, "", state.CSRFToken)) } } diff --git a/internal/server/routes.go b/internal/server/routes.go index 2db3b82..0897fca 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,10 +1,35 @@ package server -import "github.com/go-chi/chi/v5" +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) func (s *Server) registerRoutes(r chi.Router) { r.Get("/", s.indexHandler()) - r.Get("/in", s.dashboardHandler()) r.Post("/login", s.loginHandler()) r.Post("/logout", s.logoutHandler()) + r.Get("/signup", s.signupHandler()) + r.Post("/signup", s.registerHandler()) + r.Get("/in", s.dashboardHandler()) +} + +// Router returns the configured HTTP router. +func (s *Server) Router() http.Handler { + r := chi.NewRouter() + + r.Use( + middleware.RequestID, + middleware.RealIP, + middleware.Logger, + middleware.Recoverer, + s.sessionMiddleware, + s.csrfMiddleware, + ) + + s.registerRoutes(r) + + return r } diff --git a/internal/server/server.go b/internal/server/server.go index 9c60a53..7f291d9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,12 +5,8 @@ import ( "crypto/rand" "fmt" "html/template" - "net/http" "time" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/rjnemo/auth/internal/auth" "github.com/rjnemo/auth/web" ) @@ -33,6 +29,7 @@ func New() (*Server, error) { web.Templates, "templates/index.html", "templates/in.html", + "templates/signup.html", "templates/unauthorized.html", ) if err != nil { @@ -61,21 +58,6 @@ func New() (*Server, error) { }, nil } -// Router returns the configured HTTP router. -func (s *Server) Router() http.Handler { - r := chi.NewRouter() - r.Use( - middleware.RequestID, - middleware.RealIP, - middleware.Logger, - middleware.Recoverer, - s.sessionMiddleware, - s.csrfMiddleware, - ) - s.registerRoutes(r) - return r -} - func seedUser(store auth.UserStore) error { salt, hash, err := auth.HashPassword(seedPassword) if err != nil { diff --git a/internal/server/views.go b/internal/server/views.go index 84b0c85..d015175 100644 --- a/internal/server/views.go +++ b/internal/server/views.go @@ -4,6 +4,7 @@ package server type PageData struct { Email string Error string + Info string CSRFToken string CreatedAt string CreatedAtISO string @@ -20,3 +21,7 @@ func newUnauthorizedData(errMsg, token string) PageData { func newDashboardData(email, token, createdAt, createdAtISO string) PageData { return PageData{Email: email, CSRFToken: token, CreatedAt: createdAt, CreatedAtISO: createdAtISO} } + +func newSignupData(email, errMsg, token string) PageData { + return PageData{Email: email, Error: errMsg, CSRFToken: token} +} diff --git a/web/templates/index.html b/web/templates/index.html index ff8dab2..011f008 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -49,6 +49,10 @@ /> +

+ Need an account? + Create one now. +

diff --git a/web/templates/signup.html b/web/templates/signup.html new file mode 100644 index 0000000..e9e084f --- /dev/null +++ b/web/templates/signup.html @@ -0,0 +1,49 @@ + + + + + + Create Account + + + +
+

Sign up

+

Provide your email and a strong password to create an account.

+ {{if .Error}} +
+ Unable to sign up: {{.Error}} +
+ {{end}} +
+ + + + + + +
+

+ Already registered? + Return to sign in. +

+
+ +